Compare commits

...
Author SHA1 Message Date
aefril 306eabb1d9 Merge pull request 'Fix/point payment method types' (#38) from fix/point-payment-method-types into main
Reviewed-on: #38
2026-09-30 18:29:57 +02:00
efrilmandClaude Opus 5.5 892575202b fix(orders): keep the customer a new order is created for
CreateOrderContractToModel never copied customer_id, so every order from
POST /orders was saved without a customer. Paying it then earned no
EnakPoint or EnakCoin (skipped as NO_CUSTOMER, which is not logged).

The customer must now belong to the order's organization, as
SetOrderCustomer already requires: it is who earns once the order is paid,
and orders.customer_id has no foreign key. A nil UUID means no customer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:29:17 +07:00
efrilmandClaude Opus 5.5 c9654a387a fix(migrations): keep every payment method type in use
Production allows edc and delivery payment methods through a
payment_methods_type_check changed outside the migrations, and has rows of
both. 000094 rewrote the constraint with only cash, card, digital_wallet and
point, so it failed on production (in its transaction, leaving the database
dirty at 94 with nothing applied).

000094 now keeps edc and delivery, down included, and also allows qr, which
the code accepts but no constraint did. 000098 sets the same list where the
old 000094 already ran, as on staging.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:00:14 +07:00
aefril e94eb2c26d Merge pull request 'Staging' (#37) from staging into main
Reviewed-on: #37
2026-09-30 17:39:27 +02:00
aefril e8b31035f7 Merge pull request 'fix(docker): healthcheck the port the app listens on' (#36) from fix/dockerfile-healthcheck-port into staging
Reviewed-on: #36
2026-09-30 17:37:40 +02:00
efrilmandClaude Opus 5.5 100e006218 fix(docker): healthcheck the port the app listens on
The HEALTHCHECK curled localhost:3300/health, but the app listens on 4000
(server.port in infra/*.yaml, EXPOSE 4000). The check always failed, so
deployment.sh waited on 'Waiting for healthcheck...', found the container
unhealthy and rolled back to an image with the same broken check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:35:20 +07:00
aefril 609d434976 Merge pull request 'Hotfix/deployment' (#35) from hotfix/deployment into staging
Reviewed-on: #35
2026-09-30 12:06:32 +02:00
efrilmandClaude Opus 5.5 35fe5703fd fix(loyalty): keep tokens removed after merging staging
Merging staging brought back, through its own re-apply of #32, the TOKENS
campaign mapping, its test and the token_used sort fallback that c988a79
had removed, because those hunks did not conflict. This restores the five
files to c988a79.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:05:42 +07:00
efrilm e200923b72 Merge branch 'staging' into hotfix/deployment 2026-09-30 17:04:36 +07:00
efrilmandClaude Opus 5.5 c988a79d3b refactor(loyalty): remove what is left of tokens
Tokens are EnakCoin and no app uses the token names any more, so their
compatibility layer goes:

- GET /customer/tokens and its handler, service, processor and response
  types.
- total_tokens and tokens_history on GET /customer/wallet; last_updated
  now comes from the most recent row of either currency.
- token_used and tokens_remaining on game and spin responses, and
  sort_by=token_used on the game play list.
- TOKENS as a campaign type and reward type, with the mapping to COINS:
  migration 000092 already renamed the stored values.

The customer_tokens table and its entity stay, as cmd/wallet-migrate still
reads them, and LEGACY_TOKENS stays as the reference of the MIGRATION rows
it wrote. The docs list the removed names and their replacements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:55:27 +07:00
efrilmandClaude Opus 5.5 9b21af8892 fix(deploy): require explicit environment and isolate containers per env
Deploying staging from a folder checked out on main replaced the
production container, because the environment was inferred from the
current branch and both environments shared the container name
"apskel-pos".

- Environment is now a required argument (staging|production)
- Refuse to deploy when the branch doesn't match, HEAD is detached,
  or the tree is dirty
- Container name per environment; the legacy "apskel-pos" container
  is only removed by production deploys
- Abort if the port is held by another environment's container
- Confirmation prompt for production (--yes to skip)
- Fast-forward-only pull from origin/<branch>
- Keep the previous image and roll back automatically when the new
  container is not healthy; --rollback for manual rollback

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:32:25 +07:00
efrilm 582dc75543 Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts commit 4e24f9bbb0.
2026-09-30 15:31:44 +07:00
efrilm c6062c4bb9 Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts commit 4e24f9bbb0.
2026-09-30 15:31:11 +07:00
aefril 5e28b05770 Merge pull request 'Main' (#33) from main into staging
Reviewed-on: #33
2026-09-30 10:17:35 +02:00
efrilmandClaude Opus 5.5 4e24f9bbb0 Revert "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts merge commit 645da30, returning main to f0ff59f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:16:15 +07:00
aefril 645da3048e Merge pull request 'feat(loyalty): EnakPoint & EnakCoin' (#32) from feature/point-coint into main
Reviewed-on: #32
2026-09-30 10:03:03 +02:00
35 changed files with 241 additions and 300 deletions
+1 -1
View File
@@ -15,6 +15,6 @@ COPY --from=build /out/app /app
ENV TZ=Asia/Jakarta ENV TZ=Asia/Jakarta
EXPOSE 4000 EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost:3300/health || exit 1 CMD curl -fsS http://localhost:4000/health || exit 1
USER appuser USER appuser
ENTRYPOINT ["/app"] ENTRYPOINT ["/app"]
+142 -27
View File
@@ -1,62 +1,177 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
# Usage:
# ./deployment.sh staging deploy staging (branch staging, port 4001)
# ./deployment.sh production deploy production (branch main, port 4000)
# ./deployment.sh production --rollback kembali ke image sebelum deploy terakhir
# Tambahkan --yes untuk melewati konfirmasi production.
APP_NAME="apskel-pos" APP_NAME="apskel-pos"
LEGACY_CONTAINER="apskel-pos" # nama lama sebelum nama container memakai environment
# ─── Deteksi environment dari branch aktif ─────────────────────────────────── usage() {
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) echo "Usage: $0 <staging|production> [--rollback] [--yes]"
exit 1
}
case "$CURRENT_BRANCH" in fail() {
main) echo "❌ $*"
ENV_MODE="production" exit 1
}
# ─── Argumen ──────────────────────────────────────────────────────────────────
ENV_MODE="${1:-}"
shift || true
ROLLBACK=false
ASSUME_YES=false
for arg in "$@"; do
case "$arg" in
--rollback) ROLLBACK=true ;;
--yes|-y) ASSUME_YES=true ;;
*) usage ;;
esac
done
# Environment wajib disebut eksplisit, tidak lagi ditebak dari branch aktif
case "$ENV_MODE" in
production)
EXPECTED_BRANCH="main"
PORT="4000" PORT="4000"
;; ;;
staging) staging)
ENV_MODE="staging" EXPECTED_BRANCH="staging"
PORT="4001" PORT="4001"
;; ;;
*) *)
echo "❌ Branch '$CURRENT_BRANCH' tidak dikenali untuk deployment." usage
echo " Gunakan branch 'main' (production) atau 'staging' (staging)."
exit 1
;; ;;
esac esac
CONTAINER_NAME="$APP_NAME" CONTAINER_NAME="$APP_NAME-$ENV_MODE"
IMAGE_NAME="$APP_NAME:$ENV_MODE" IMAGE_NAME="$APP_NAME:$ENV_MODE"
PREVIOUS_IMAGE="$APP_NAME:$ENV_MODE-previous"
CONFIG_FILE="infra/$ENV_MODE.yaml"
# ─── Validasi repo ────────────────────────────────────────────────────────────
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" = "HEAD" ]; then
fail "Repo sedang detached HEAD. Jalankan 'git checkout $EXPECTED_BRANCH' dulu."
fi
if [ "$CURRENT_BRANCH" != "$EXPECTED_BRANCH" ]; then
fail "Deploy $ENV_MODE harus dari branch '$EXPECTED_BRANCH', folder ini di branch '$CURRENT_BRANCH'.
Pakai folder terpisah per environment, jangan pindah branch di folder yang sedang dipakai container lain."
fi
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
fail "Ada perubahan yang belum di-commit di folder ini. Bersihkan dulu sebelum deploy."
fi
[ -f "$CONFIG_FILE" ] || fail "Config file '$CONFIG_FILE' tidak ditemukan."
# ─── Pastikan port tidak dipakai container environment lain ──────────────────
OTHER_ON_PORT=$(docker ps --filter "publish=$PORT" --format '{{.Names}}' \
| grep -vx "$CONTAINER_NAME" \
| { if [ "$ENV_MODE" = "production" ]; then grep -vx "$LEGACY_CONTAINER"; else cat; fi; } \
|| true)
if [ -n "$OTHER_ON_PORT" ]; then
fail "Port $PORT sedang dipakai container lain: $OTHER_ON_PORT"
fi
echo "📦 Environment : $ENV_MODE" echo "📦 Environment : $ENV_MODE"
echo "🌿 Branch : $CURRENT_BRANCH" echo "🌿 Branch : $CURRENT_BRANCH"
echo "🐳 Container : $CONTAINER_NAME" echo "🐳 Container : $CONTAINER_NAME"
echo "🔌 Port : $PORT" echo "🔌 Port : $PORT"
echo "📁 Folder : $(pwd)"
[ "$ROLLBACK" = true ] && echo "⏪ Mode : ROLLBACK ke $PREVIOUS_IMAGE"
echo "" echo ""
# ─── Pastikan config file ada ───────────────────────────────────────────────── # ─── Konfirmasi production ────────────────────────────────────────────────────
CONFIG_FILE="infra/$ENV_MODE.yaml" if [ "$ENV_MODE" = "production" ] && [ "$ASSUME_YES" != true ]; then
if [ ! -f "$CONFIG_FILE" ]; then read -r -p "⚠️ Ini PRODUCTION. Ketik 'production' untuk lanjut: " CONFIRM
echo "❌ Config file '$CONFIG_FILE' tidak ditemukan." [ "$CONFIRM" = "production" ] || fail "Dibatalkan."
exit 1
fi fi
echo "🔄 Pulling latest code..." run_container() {
git pull local image="$1"
echo "🛑 Stopping and removing old container..."
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
# Container production lama masih bernama 'apskel-pos'; staging tidak boleh menyentuhnya
if [ "$ENV_MODE" = "production" ]; then
docker rm -f "$LEGACY_CONTAINER" 2>/dev/null || true
fi
echo "🚀 Running container from $image..."
docker run -d --name "$CONTAINER_NAME" \
--restart unless-stopped \
-p "$PORT:4000" \
-e TZ=Asia/Jakarta \
-e ENV_MODE="$ENV_MODE" \
-v "$(pwd)/infra":/infra:ro \
-v "$(pwd)/templates":/templates:ro \
"$image" >/dev/null
}
wait_healthy() {
echo "🩺 Waiting for healthcheck..."
for _ in $(seq 1 36); do
local status
status=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER_NAME" 2>/dev/null || echo "missing")
case "$status" in
healthy) return 0 ;;
unhealthy|exited|dead|missing) return 1 ;;
esac
sleep 5
done
return 1
}
# ─── Rollback ─────────────────────────────────────────────────────────────────
if [ "$ROLLBACK" = true ]; then
docker image inspect "$PREVIOUS_IMAGE" >/dev/null 2>&1 || fail "Image '$PREVIOUS_IMAGE' tidak ada, tidak bisa rollback."
run_container "$PREVIOUS_IMAGE"
wait_healthy || fail "Container hasil rollback tidak healthy. Cek: docker logs $CONTAINER_NAME"
docker tag "$PREVIOUS_IMAGE" "$IMAGE_NAME"
echo "✅ Rollback $ENV_MODE complete."
exit 0
fi
# ─── Deploy ───────────────────────────────────────────────────────────────────
echo "🔄 Pulling latest code (origin/$EXPECTED_BRANCH)..."
git fetch origin "$EXPECTED_BRANCH"
git merge --ff-only "origin/$EXPECTED_BRANCH"
echo " Commit: $(git log -1 --format='%h %s')"
# Simpan image yang sedang jalan untuk rollback
if docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then
docker tag "$IMAGE_NAME" "$PREVIOUS_IMAGE"
fi
echo "🐳 Building Docker image ($ENV_MODE)..." echo "🐳 Building Docker image ($ENV_MODE)..."
docker build --target production -t "$IMAGE_NAME" . docker build --target production -t "$IMAGE_NAME" .
echo "🛑 Stopping and removing old container..." run_container "$IMAGE_NAME"
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
echo "🚀 Running new container..." if ! wait_healthy; then
docker run -d --name "$CONTAINER_NAME" \ echo "❌ Container baru tidak healthy. Log terakhir:"
-p "$PORT:4000" \ docker logs --tail 50 "$CONTAINER_NAME" || true
-e TZ=Asia/Jakarta \ if docker image inspect "$PREVIOUS_IMAGE" >/dev/null 2>&1; then
-e ENV_MODE="$ENV_MODE" \ echo "⏪ Rolling back ke $PREVIOUS_IMAGE..."
-v "$(pwd)/infra":/infra:ro \ run_container "$PREVIOUS_IMAGE"
-v "$(pwd)/templates":/templates:ro \ wait_healthy || fail "Rollback juga tidak healthy. Cek manual: docker logs $CONTAINER_NAME"
"$IMAGE_NAME" # Jangan biarkan image rusak jadi 'previous' di deploy berikutnya
docker tag "$PREVIOUS_IMAGE" "$IMAGE_NAME"
fail "Deploy gagal, $ENV_MODE sudah dikembalikan ke image sebelumnya."
fi
fail "Deploy gagal dan tidak ada image sebelumnya untuk rollback."
fi
echo "" echo ""
echo "✅ Deployment $ENV_MODE complete." echo "✅ Deployment $ENV_MODE complete."
echo " Container : $CONTAINER_NAME" echo " Container : $CONTAINER_NAME"
echo " Port : $PORT" echo " Port : $PORT"
echo " Rollback : $0 $ENV_MODE --rollback"
+2 -5
View File
@@ -426,14 +426,11 @@ Semua nilai `data` berupa string. Push hanya sampai ke device yang terdaftar lew
### Endpoint dan field deprecated ### Endpoint dan field deprecated
Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi pindah. Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi pindah. Semua yang bernama token (`/customer/tokens`, `total_tokens`, `tokens_history`, `token_used`, `tokens_remaining`, campaign `TOKENS`) sudah dihapus; pakai `coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`.
| Lama | Pengganti | | Lama | Pengganti |
| --- | --- | | --- | --- |
| `GET /customer/points` | `GET /customer/wallet` → `point_balance` | | `GET /customer/points` | `GET /customer/wallet` → `point_balance` |
| `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` | | `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
| `total_points`, `total_tokens`, `points_history`, `tokens_history`, `last_updated` di `/customer/wallet` | `point_balance`, `coin_balance`, `recent_transactions` |
| `token_used`, `tokens_remaining` di response game | `coins_used`, `coins_remaining` |
| `sort_by=token_used` di daftar game play | `sort_by=coins_used` |
Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md). Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md).
+9 -10
View File
@@ -474,16 +474,14 @@ Response:
```json ```json
{ {
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "token_used": 1, "created_at": "…" }, "game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" },
"prize_won": { "id": "…", "name": "Voucher 10rb", … }, "prize_won": { "id": "…", "name": "Voucher 10rb", … },
"coins_remaining": 7, "coins_remaining": 7
"tokens_remaining": 7
} }
``` ```
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada
EnakCoin yang terpotong. Baca `coins_used` dan `coins_remaining`; `token_used` dan EnakCoin yang terpotong.
`tokens_remaining` hanya salinan untuk versi aplikasi lama.
Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1. Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1.
@@ -497,13 +495,14 @@ pindah. Aplikasi baru jangan memakainya.
| Lama | Ganti dengan | | Lama | Ganti dengan |
|---|---| |---|---|
| `GET /customer/points` | `GET /customer/wallet` (`point_balance`) | | `GET /customer/points` | `GET /customer/wallet` (`point_balance`) |
| `GET /customer/tokens` | `GET /customer/wallet` (`coin_balance`) | | `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
| `total_points`, `total_tokens`, `points_history`, `tokens_history`, `last_updated` di `/customer/wallet` | `point_balance`, `coin_balance`, `recent_transactions` |
| `token_used`, `tokens_remaining` di respons game | `coins_used`, `coins_remaining` |
| `sort_by=token_used` di daftar game play | `sort_by=coins_used` |
Beri tahu tim backend setelah aplikasi yang beredar tidak lagi memakai kolom kiri, Beri tahu tim backend setelah aplikasi yang beredar tidak lagi memakai kolom kiri,
supaya alias dan tabel lama (`customer_points`, `customer_tokens`) bisa dihapus. supaya alias ini bisa dihapus.
Semua yang bernama token sudah dihapus: `GET /customer/tokens`, `total_tokens`,
`tokens_history`, `token_used`, `tokens_remaining`, dan nilai `TOKENS` di campaign. Pakai
`coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`.
--- ---
-1
View File
@@ -52,7 +52,6 @@ const (
TableEntity = "table" TableEntity = "table"
// Gamification entities // Gamification entities
CustomerPointsEntity = "customer_points" CustomerPointsEntity = "customer_points"
CustomerTokensEntity = "customer_tokens"
TierEntity = "tier" TierEntity = "tier"
GameEntity = "game" GameEntity = "game"
GamePrizeEntity = "game_prize" GamePrizeEntity = "game_prize"
+5 -5
View File
@@ -12,7 +12,7 @@ import (
type CreateCampaignRequest struct { type CreateCampaignRequest struct {
Name string `json:"name" binding:"required,min=1,max=150"` Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"` Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS MIXED"`
StartDate time.Time `json:"start_date" binding:"required"` StartDate time.Time `json:"start_date" binding:"required"`
EndDate time.Time `json:"end_date" binding:"required"` EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
@@ -25,7 +25,7 @@ type UpdateCampaignRequest struct {
ID uuid.UUID `json:"id" binding:"required"` ID uuid.UUID `json:"id" binding:"required"`
Name string `json:"name" binding:"required,min=1,max=150"` Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"` Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS MIXED"`
StartDate time.Time `json:"start_date" binding:"required"` StartDate time.Time `json:"start_date" binding:"required"`
EndDate time.Time `json:"end_date" binding:"required"` EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
@@ -58,7 +58,7 @@ type CreateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"` CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"` ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"` RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"` RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -70,7 +70,7 @@ type UpdateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"` CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"` ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"` RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"` RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -142,7 +142,7 @@ type ListCampaignRulesResponse struct {
type CampaignRuleStruct struct { type CampaignRuleStruct struct {
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"` ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"` RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"` RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -25,26 +25,6 @@ type PointsHistoryItem struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type GetCustomerTokensResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data *GetCustomerTokensResponseData `json:"data,omitempty"`
}
type GetCustomerTokensResponseData struct {
TotalTokens int64 `json:"total_tokens"`
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
LastUpdated time.Time `json:"last_updated"`
}
type TokensHistoryItem struct {
ID string `json:"id"`
Tokens int64 `json:"tokens"`
Type string `json:"type"` // EARNED, REDEEMED, EXPIRED
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
}
type GetCustomerWalletResponse struct { type GetCustomerWalletResponse struct {
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message"` Message string `json:"message"`
@@ -53,8 +33,6 @@ type GetCustomerWalletResponse struct {
type GetCustomerWalletResponseData struct { type GetCustomerWalletResponseData struct {
TotalPoints int64 `json:"total_points"` TotalPoints int64 `json:"total_points"`
TotalTokens int64 `json:"total_tokens"`
PointsHistory []PointsHistoryItem `json:"points_history,omitempty"` PointsHistory []PointsHistoryItem `json:"points_history,omitempty"`
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
LastUpdated time.Time `json:"last_updated"` LastUpdated time.Time `json:"last_updated"`
} }
+6 -10
View File
@@ -14,13 +14,11 @@ type CreateGamePlayRequest struct {
} }
type GamePlayResponse struct { type GamePlayResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
GameID uuid.UUID `json:"game_id"` GameID uuid.UUID `json:"game_id"`
CustomerID uuid.UUID `json:"customer_id"` CustomerID uuid.UUID `json:"customer_id"`
PrizeID *uuid.UUID `json:"prize_id,omitempty"` PrizeID *uuid.UUID `json:"prize_id,omitempty"`
CoinsUsed int `json:"coins_used"` CoinsUsed int `json:"coins_used"`
// Deprecated: the same as CoinsUsed, kept until the apps read coins_used.
TokenUsed int `json:"token_used"`
RandomSeed *string `json:"random_seed,omitempty"` RandomSeed *string `json:"random_seed,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Game *GameResponse `json:"game,omitempty"` Game *GameResponse `json:"game,omitempty"`
@@ -35,7 +33,7 @@ type ListGamePlaysRequest struct {
GameID *uuid.UUID `json:"game_id"` GameID *uuid.UUID `json:"game_id"`
CustomerID *uuid.UUID `json:"customer_id"` CustomerID *uuid.UUID `json:"customer_id"`
PrizeID *uuid.UUID `json:"prize_id"` PrizeID *uuid.UUID `json:"prize_id"`
SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"` SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used"`
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"` SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
} }
@@ -58,6 +56,4 @@ type PlayGameResponse struct {
GamePlay GamePlayResponse `json:"game_play"` GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
CoinsRemaining int64 `json:"coins_remaining"` CoinsRemaining int64 `json:"coins_remaining"`
// Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining.
TokensRemaining int64 `json:"tokens_remaining"`
} }
-2
View File
@@ -17,6 +17,4 @@ type SpinGameResponseData struct {
GamePlay GamePlayResponse `json:"game_play"` GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
CoinsRemaining int64 `json:"coins_remaining"` CoinsRemaining int64 `json:"coins_remaining"`
// Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining.
TokensRemaining int64 `json:"tokens_remaining"`
} }
-21
View File
@@ -16,27 +16,6 @@ const (
CampaignTypeMixed CampaignType = "MIXED" CampaignTypeMixed CampaignType = "MIXED"
) )
// legacyCampaignTokens is what COINS was called before tokens became EnakCoin
// (docs/prd-point-coin.md §10). Requests may still send it; it is stored as COINS.
const legacyCampaignTokens = "TOKENS"
// NormalizeCampaignType maps the legacy TOKENS to COINS and leaves anything else as is.
func NormalizeCampaignType(t string) CampaignType {
if t == legacyCampaignTokens {
return CampaignTypeCoins
}
return CampaignType(t)
}
// NormalizeCampaignRewardType maps the legacy TOKENS to COINS and leaves anything
// else as is.
func NormalizeCampaignRewardType(t string) CampaignRewardType {
if t == legacyCampaignTokens {
return CampaignRewardTypeCoins
}
return CampaignRewardType(t)
}
type RuleType string type RuleType string
const ( const (
-18
View File
@@ -1,18 +0,0 @@
package entities
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNormalizeCampaignTypesMapsLegacyTokensToCoins(t *testing.T) {
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("TOKENS"))
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("COINS"))
assert.Equal(t, CampaignTypePoints, NormalizeCampaignType("POINTS"))
assert.Equal(t, CampaignType(""), NormalizeCampaignType(""), "an empty filter stays empty")
assert.Equal(t, CampaignRewardTypeCoins, NormalizeCampaignRewardType("TOKENS"))
assert.Equal(t, CampaignRewardTypeReward, NormalizeCampaignRewardType("REWARD"))
assert.Equal(t, CampaignRewardType(""), NormalizeCampaignRewardType(""))
}
@@ -59,40 +59,6 @@ func (h *CustomerPointsHandler) GetCustomerPoints(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerPoints") util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerPoints")
} }
func (h *CustomerPointsHandler) GetCustomerTokens(c *gin.Context) {
ctx := c.Request.Context()
// Get customer ID from context (set by middleware)
customerID, exists := c.Get("customer_id")
if !exists {
logger.FromContext(ctx).Error("Customer ID not found in context")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
}), "CustomerPointsHandler::GetCustomerTokens")
return
}
customerIDStr, ok := customerID.(string)
if !ok {
logger.FromContext(ctx).Error("Invalid customer ID type in context")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Invalid customer ID"),
}), "CustomerPointsHandler::GetCustomerTokens")
return
}
response, err := h.customerPointsService.GetCustomerTokens(ctx, customerIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerTokens -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerTokens")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerTokens")
}
func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) { func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
+4 -8
View File
@@ -25,7 +25,7 @@ import (
) )
// Drives the customer wallet endpoints over HTTP down to Postgres, to show the app // Drives the customer wallet endpoints over HTTP down to Postgres, to show the app
// gets the right numbers from the new endpoints and from the old /points, /tokens // gets the right numbers from the new endpoints and from the old /points
// and /wallet shapes. Needs TEST_DATABASE_URL pointing at a migrated database; see // and /wallet shapes. Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go. // internal/repository/wallet_repository_test.go.
func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) { func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
@@ -111,7 +111,6 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
}{{"/a", customer}, {"/b", other}, {"/gone", uuid.New()}} { }{{"/a", customer}, {"/b", other}, {"/gone", uuid.New()}} {
g := router.Group(who.prefix, as(who.id)) g := router.Group(who.prefix, as(who.id))
g.GET("/points", h.GetCustomerPoints) g.GET("/points", h.GetCustomerPoints)
g.GET("/tokens", h.GetCustomerTokens)
g.GET("/wallet", h.GetCustomerWallet) g.GET("/wallet", h.GetCustomerWallet)
g.GET("/wallet/transactions", h.GetCustomerWalletTransactions) g.GET("/wallet/transactions", h.GetCustomerWalletTransactions)
} }
@@ -128,16 +127,12 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
return body["data"].(map[string]any)["data"].(map[string]any) return body["data"].(map[string]any)["data"].(map[string]any)
} }
// Old app: /points and /tokens keep their shape and show the spendable balance. // Old app: /points keeps its shape and shows the spendable balance.
status, body := get("/a/points") status, body := get("/a/points")
require.Equal(t, http.StatusOK, status, body) require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 250, inner(body)["total_points"], "300 - 50; the expired 20 is not spendable") assert.EqualValues(t, 250, inner(body)["total_points"], "300 - 50; the expired 20 is not spendable")
assert.Len(t, inner(body)["points_history"], 3) assert.Len(t, inner(body)["points_history"], 3)
status, body = get("/a/tokens")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 8, inner(body)["total_tokens"])
// /wallet: new fields, with the old ones still there. // /wallet: new fields, with the old ones still there.
status, body = get("/a/wallet") status, body = get("/a/wallet")
require.Equal(t, http.StatusOK, status, body) require.Equal(t, http.StatusOK, status, body)
@@ -151,9 +146,10 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
assert.Equal(t, map[string]any{"amount": float64(250), "date": time.Date(next, 7, 2, 0, 0, 0, 0, time.UTC).Format("2006-01-02")}, assert.Equal(t, map[string]any{"amount": float64(250), "date": time.Date(next, 7, 2, 0, 0, 0, 0, time.UTC).Format("2006-01-02")},
nearest["point"], "both lots fall on 2 July in Jakarta although they are on different UTC days") nearest["point"], "both lots fall on 2 July in Jakarta although they are on different UTC days")
assert.Nil(t, nearest["coin"]) assert.Nil(t, nearest["coin"])
for _, legacy := range []string{"total_points", "total_tokens", "points_history", "tokens_history", "last_updated"} { for _, legacy := range []string{"total_points", "points_history", "last_updated"} {
assert.Contains(t, w, legacy) assert.Contains(t, w, legacy)
} }
assert.NotContains(t, w, "total_tokens", "tokens are gone; EnakCoin is coin_balance")
assert.EqualValues(t, 250, w["total_points"]) assert.EqualValues(t, 250, w["total_points"])
recent := w["recent_transactions"].([]any) recent := w["recent_transactions"].([]any)
+4 -4
View File
@@ -71,7 +71,7 @@ func ToCampaignEntity(request *contract.CreateCampaignRequest) *entities.Campaig
return &entities.Campaign{ return &entities.Campaign{
Name: request.Name, Name: request.Name,
Description: request.Description, Description: request.Description,
Type: entities.NormalizeCampaignType(request.Type), Type: entities.CampaignType(request.Type),
StartDate: request.StartDate, StartDate: request.StartDate,
EndDate: request.EndDate, EndDate: request.EndDate,
IsActive: request.IsActive, IsActive: request.IsActive,
@@ -91,7 +91,7 @@ func ToCampaignEntityFromUpdate(request *contract.UpdateCampaignRequest) *entiti
ID: request.ID, ID: request.ID,
Name: request.Name, Name: request.Name,
Description: request.Description, Description: request.Description,
Type: entities.NormalizeCampaignType(request.Type), Type: entities.CampaignType(request.Type),
StartDate: request.StartDate, StartDate: request.StartDate,
EndDate: request.EndDate, EndDate: request.EndDate,
IsActive: request.IsActive, IsActive: request.IsActive,
@@ -118,7 +118,7 @@ func ToCampaignRuleEntity(request *contract.CampaignRuleStruct, campaignID uuid.
CampaignID: campaignID, CampaignID: campaignID,
RuleType: entities.RuleType(request.RuleType), RuleType: entities.RuleType(request.RuleType),
ConditionValue: request.ConditionValue, ConditionValue: request.ConditionValue,
RewardType: entities.NormalizeCampaignRewardType(request.RewardType), RewardType: entities.CampaignRewardType(request.RewardType),
RewardValue: request.RewardValue, RewardValue: request.RewardValue,
RewardSubtype: rewardSubtype, RewardSubtype: rewardSubtype,
RewardRefID: request.RewardRefID, RewardRefID: request.RewardRefID,
@@ -142,7 +142,7 @@ func ToCampaignRuleEntityFromUpdate(request *contract.CampaignRuleStruct, campai
CampaignID: campaignID, CampaignID: campaignID,
RuleType: entities.RuleType(request.RuleType), RuleType: entities.RuleType(request.RuleType),
ConditionValue: request.ConditionValue, ConditionValue: request.ConditionValue,
RewardType: entities.NormalizeCampaignRewardType(request.RewardType), RewardType: entities.CampaignRewardType(request.RewardType),
RewardValue: request.RewardValue, RewardValue: request.RewardValue,
RewardSubtype: rewardSubtype, RewardSubtype: rewardSubtype,
RewardRefID: request.RewardRefID, RewardRefID: request.RewardRefID,
+3 -5
View File
@@ -36,10 +36,9 @@ func SpinGameResponseDataModelToContract(data *models.SpinGameResponseData) *con
} }
return &contract.SpinGameResponseData{ return &contract.SpinGameResponseData{
GamePlay: GamePlayResponseModelToContract(&data.GamePlay), GamePlay: GamePlayResponseModelToContract(&data.GamePlay),
PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon), PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon),
CoinsRemaining: data.CoinsRemaining, CoinsRemaining: data.CoinsRemaining,
TokensRemaining: data.CoinsRemaining,
} }
} }
@@ -55,7 +54,6 @@ func GamePlayResponseModelToContract(resp *models.GamePlayResponse) contract.Gam
CustomerID: resp.CustomerID, CustomerID: resp.CustomerID,
PrizeID: resp.PrizeID, PrizeID: resp.PrizeID,
CoinsUsed: resp.CoinsUsed, CoinsUsed: resp.CoinsUsed,
TokenUsed: resp.CoinsUsed,
RandomSeed: resp.RandomSeed, RandomSeed: resp.RandomSeed,
CreatedAt: resp.CreatedAt, CreatedAt: resp.CreatedAt,
Game: nil, // Optional field - can be populated separately if needed Game: nil, // Optional field - can be populated separately if needed
+5 -5
View File
@@ -11,7 +11,7 @@ import (
type CreateCampaignRequest struct { type CreateCampaignRequest struct {
Name string `json:"name" binding:"required,min=1,max=150"` Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"` Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS MIXED"`
StartDate time.Time `json:"start_date" binding:"required"` StartDate time.Time `json:"start_date" binding:"required"`
EndDate time.Time `json:"end_date" binding:"required"` EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
@@ -25,7 +25,7 @@ type UpdateCampaignRequest struct {
ID uuid.UUID `json:"id" binding:"required"` ID uuid.UUID `json:"id" binding:"required"`
Name string `json:"name" binding:"required,min=1,max=150"` Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"` Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS MIXED"`
StartDate time.Time `json:"start_date" binding:"required"` StartDate time.Time `json:"start_date" binding:"required"`
EndDate time.Time `json:"end_date" binding:"required"` EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
@@ -88,7 +88,7 @@ type CreateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"` CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"` ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"` RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"` RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -100,7 +100,7 @@ type UpdateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"` CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"` ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"` RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"` RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -126,7 +126,7 @@ type ListCampaignRulesResponse struct {
type CampaignRuleStruct struct { type CampaignRuleStruct struct {
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"` ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"` RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"` RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
-22
View File
@@ -25,26 +25,6 @@ type PointsHistoryItem struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type GetCustomerTokensResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data *GetCustomerTokensResponseData `json:"data,omitempty"`
}
type GetCustomerTokensResponseData struct {
TotalTokens int64 `json:"total_tokens"`
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
LastUpdated time.Time `json:"last_updated"`
}
type TokensHistoryItem struct {
ID string `json:"id"`
Tokens int64 `json:"tokens"`
Type string `json:"type"` // EARNED, REDEEMED, EXPIRED
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
}
type GetCustomerWalletResponse struct { type GetCustomerWalletResponse struct {
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message"` Message string `json:"message"`
@@ -66,8 +46,6 @@ type GetCustomerWalletResponseData struct {
// Deprecated: the shape of this endpoint before the wallet, kept for app versions // Deprecated: the shape of this endpoint before the wallet, kept for app versions
// that still read it. Filled from the wallet like the fields above. // that still read it. Filled from the wallet like the fields above.
TotalPoints int64 `json:"total_points"` TotalPoints int64 `json:"total_points"`
TotalTokens int64 `json:"total_tokens"`
PointsHistory []PointsHistoryItem `json:"points_history,omitempty"` PointsHistory []PointsHistoryItem `json:"points_history,omitempty"`
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
LastUpdated time.Time `json:"last_updated"` LastUpdated time.Time `json:"last_updated"`
} }
+1 -1
View File
@@ -33,7 +33,7 @@ type ListGamePlaysQuery struct {
GameID *uuid.UUID `query:"game_id"` GameID *uuid.UUID `query:"game_id"`
CustomerID *uuid.UUID `query:"customer_id"` CustomerID *uuid.UUID `query:"customer_id"`
PrizeID *uuid.UUID `query:"prize_id"` PrizeID *uuid.UUID `query:"prize_id"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"` SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
} }
+4 -4
View File
@@ -70,7 +70,7 @@ func (p *campaignProcessor) ListCampaigns(ctx context.Context, req *contract.Lis
Page: req.Page, Page: req.Page,
Limit: req.Limit, Limit: req.Limit,
Search: req.Search, Search: req.Search,
Type: string(entities.NormalizeCampaignType(req.Type)), Type: req.Type,
IsActive: req.IsActive, IsActive: req.IsActive,
ShowOnApp: req.ShowOnApp, ShowOnApp: req.ShowOnApp,
StartDate: req.StartDate, StartDate: req.StartDate,
@@ -178,7 +178,7 @@ func (p *campaignRuleProcessor) CreateCampaignRule(ctx context.Context, req *con
CampaignID: req.CampaignID, CampaignID: req.CampaignID,
RuleType: entities.RuleType(req.RuleType), RuleType: entities.RuleType(req.RuleType),
ConditionValue: req.ConditionValue, ConditionValue: req.ConditionValue,
RewardType: entities.NormalizeCampaignRewardType(req.RewardType), RewardType: entities.CampaignRewardType(req.RewardType),
RewardValue: req.RewardValue, RewardValue: req.RewardValue,
RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype), RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype),
RewardRefID: req.RewardRefID, RewardRefID: req.RewardRefID,
@@ -218,7 +218,7 @@ func (p *campaignRuleProcessor) ListCampaignRules(ctx context.Context, req *cont
Limit: req.Limit, Limit: req.Limit,
CampaignID: req.CampaignID, CampaignID: req.CampaignID,
RuleType: req.RuleType, RuleType: req.RuleType,
RewardType: string(entities.NormalizeCampaignRewardType(req.RewardType)), RewardType: req.RewardType,
} }
// Get from repository // Get from repository
@@ -247,7 +247,7 @@ func (p *campaignRuleProcessor) UpdateCampaignRule(ctx context.Context, req *con
CampaignID: req.CampaignID, CampaignID: req.CampaignID,
RuleType: entities.RuleType(req.RuleType), RuleType: entities.RuleType(req.RuleType),
ConditionValue: req.ConditionValue, ConditionValue: req.ConditionValue,
RewardType: entities.NormalizeCampaignRewardType(req.RewardType), RewardType: entities.CampaignRewardType(req.RewardType),
RewardValue: req.RewardValue, RewardValue: req.RewardValue,
RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype), RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype),
RewardRefID: req.RewardRefID, RewardRefID: req.RewardRefID,
@@ -22,7 +22,7 @@ func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *rep
} }
} }
// The three endpoints below are the customer app's balance screens. They read the // The endpoints below are the customer app's balance screens. They read the
// EnakPoint & EnakCoin wallet; customer_points and customer_tokens are no longer used // EnakPoint & EnakCoin wallet; customer_points and customer_tokens are no longer used
// for balances (docs/prd-point-coin.md §9). // for balances (docs/prd-point-coin.md §9).
@@ -42,22 +42,6 @@ func (p *CustomerPointsProcessor) GetCustomerTotalPointsAPI(ctx context.Context,
}, nil }, nil
} }
func (p *CustomerPointsProcessor) GetCustomerTotalTokensAPI(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) {
id, err := parseWalletCustomerID(customerID)
if err != nil {
return nil, err
}
data, err := p.walletQuery.LegacyTokens(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get customer tokens: %w", err)
}
return &models.GetCustomerTokensResponse{
Status: "SUCCESS",
Message: "Customer tokens retrieved successfully.",
Data: data,
}, nil
}
func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) { func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) {
id, err := parseWalletCustomerID(customerID) id, err := parseWalletCustomerID(customerID)
if err != nil { if err != nil {
@@ -99,10 +99,6 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis
} }
offset := (query.Page - 1) * query.Limit offset := (query.Page - 1) * query.Limit
// The column was renamed; the old name still sorts the same way.
if query.SortBy == "token_used" {
query.SortBy = "coins_used"
}
// Get game plays from repository // Get game plays from repository
gamePlays, total, err := p.gamePlayRepo.List( gamePlays, total, err := p.gamePlayRepo.List(
+13 -1
View File
@@ -316,6 +316,18 @@ func resolveLineQuantity(product *entities.Product, quantity int, weight *float6
} }
func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) { func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) {
// The order's customer earns EnakPoint and EnakCoin once it is paid, so it must be
// one of the organization's own; orders.customer_id has no foreign key to catch it.
customerID := req.CustomerID
if customerID != nil && *customerID == uuid.Nil {
customerID = nil
}
if customerID != nil {
if _, err := p.customerRepo.GetByIDAndOrganization(ctx, *customerID, organizationID); err != nil {
return nil, fmt.Errorf("customer not found or does not belong to the organization: %w", err)
}
}
orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID) orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate order number: %w", err) return nil, fmt.Errorf("failed to generate order number: %w", err)
@@ -401,7 +413,7 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
OrganizationID: organizationID, OrganizationID: organizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
UserID: req.UserID, UserID: req.UserID,
CustomerID: req.CustomerID, CustomerID: customerID,
OrderNumber: orderNumber, OrderNumber: orderNumber,
TableNumber: req.TableNumber, TableNumber: req.TableNumber,
OrderType: entities.OrderType(req.OrderType), OrderType: entities.OrderType(req.OrderType),
+2 -35
View File
@@ -82,10 +82,6 @@ func (p *WalletQueryProcessor) Summary(ctx context.Context, customerID uuid.UUID
if err != nil { if err != nil {
return nil, err return nil, err
} }
tokens, err := p.legacyTokens(ctx, customerID, balances)
if err != nil {
return nil, err
}
data := &models.GetCustomerWalletResponseData{ data := &models.GetCustomerWalletResponseData{
PointBalance: balances[constants.WalletCurrencyPoint], PointBalance: balances[constants.WalletCurrencyPoint],
@@ -94,13 +90,10 @@ func (p *WalletQueryProcessor) Summary(ctx context.Context, customerID uuid.UUID
PointDiscountValue: balances[constants.WalletCurrencyPoint] * pointValue, PointDiscountValue: balances[constants.WalletCurrencyPoint] * pointValue,
RecentTransactions: recent, RecentTransactions: recent,
TotalPoints: points.TotalPoints, TotalPoints: points.TotalPoints,
TotalTokens: tokens.TotalTokens,
PointsHistory: points.PointsHistory, PointsHistory: points.PointsHistory,
TokensHistory: tokens.TokensHistory,
LastUpdated: points.LastUpdated,
} }
if tokens.LastUpdated.After(data.LastUpdated) { if len(recent) > 0 {
data.LastUpdated = tokens.LastUpdated data.LastUpdated = recent[0].CreatedAt
} }
for _, e := range expiring { for _, e := range expiring {
entry := &models.CustomerWalletExpiring{Amount: e.Amount, Date: e.Date} entry := &models.CustomerWalletExpiring{Amount: e.Amount, Date: e.Date}
@@ -161,32 +154,6 @@ func (p *WalletQueryProcessor) legacyPoints(ctx context.Context, customerID uuid
return data, nil return data, nil
} }
// LegacyTokens is the pre-wallet GET /customer/tokens. Tokens are EnakCoin now.
func (p *WalletQueryProcessor) LegacyTokens(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerTokensResponseData, error) {
balances, err := p.repo.SpendableBalances(ctx, customerID, p.now())
if err != nil {
return nil, err
}
return p.legacyTokens(ctx, customerID, balances)
}
func (p *WalletQueryProcessor) legacyTokens(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerTokensResponseData, error) {
recent, err := p.recent(ctx, customerID, constants.WalletCurrencyCoin, walletLegacyHistorySize)
if err != nil {
return nil, err
}
data := &models.GetCustomerTokensResponseData{TotalTokens: balances[constants.WalletCurrencyCoin]}
for _, tx := range recent {
data.TokensHistory = append(data.TokensHistory, models.TokensHistoryItem{
ID: tx.ID.String(), Tokens: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt,
})
}
if len(recent) > 0 {
data.LastUpdated = recent[0].CreatedAt
}
return data, nil
}
func (p *WalletQueryProcessor) recent(ctx context.Context, customerID uuid.UUID, currency string, limit int) ([]models.CustomerWalletTransaction, error) { func (p *WalletQueryProcessor) recent(ctx context.Context, customerID uuid.UUID, currency string, limit int) ([]models.CustomerWalletTransaction, error) {
rows, _, err := p.repo.ListTransactions(ctx, repository.WalletTransactionFilter{ rows, _, err := p.repo.ListTransactions(ctx, repository.WalletTransactionFilter{
CustomerID: customerID, Currency: currency, Limit: limit, CustomerID: customerID, Currency: currency, Limit: limit,
@@ -122,7 +122,6 @@ func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.
// The pre-wallet fields are still filled, from the wallet. // The pre-wallet fields are still filled, from the wallet.
assert.Equal(t, int64(250), data.TotalPoints) assert.Equal(t, int64(250), data.TotalPoints)
assert.Equal(t, int64(8), data.TotalTokens)
require.Len(t, data.PointsHistory, 2) require.Len(t, data.PointsHistory, 2)
assert.Equal(t, int64(-50), data.PointsHistory[0].Points) assert.Equal(t, int64(-50), data.PointsHistory[0].Points)
assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type) assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type)
@@ -174,12 +173,6 @@ func TestWalletQueryProcessor_LegacyEndpointsSplitByCurrency(t *testing.T) {
assert.Equal(t, int64(100), points.TotalPoints) assert.Equal(t, int64(100), points.TotalPoints)
require.Len(t, points.PointsHistory, 1) require.Len(t, points.PointsHistory, 1)
assert.Equal(t, int64(100), points.PointsHistory[0].Points) assert.Equal(t, int64(100), points.PointsHistory[0].Points)
tokens, err := p.LegacyTokens(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, int64(8), tokens.TotalTokens)
require.Len(t, tokens.TokensHistory, 1)
assert.Equal(t, int64(8), tokens.TokensHistory[0].Tokens)
} }
func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) { func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) {
-1
View File
@@ -169,7 +169,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
customer.Use(r.customerAuthMiddleware.ValidateCustomerToken()) customer.Use(r.customerAuthMiddleware.ValidateCustomerToken())
{ {
customer.GET("/points", r.customerPointsHandler.GetCustomerPoints) customer.GET("/points", r.customerPointsHandler.GetCustomerPoints)
customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens)
customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet)
customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions) customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions)
customer.GET("/wallet/expiring", r.customerPointsHandler.GetCustomerWalletExpiring) customer.GET("/wallet/expiring", r.customerPointsHandler.GetCustomerWalletExpiring)
+5 -5
View File
@@ -177,7 +177,7 @@ func (s *campaignService) CreateCampaignRule(ctx context.Context, req *contract.
} }
// Validate reward value based on reward type // Validate reward value based on reward type
if req.RewardType == "POINTS" || req.RewardType == "COINS" || req.RewardType == "TOKENS" { if req.RewardType == "POINTS" || req.RewardType == "COINS" {
if req.RewardValue == nil || *req.RewardValue <= 0 { if req.RewardValue == nil || *req.RewardValue <= 0 {
return nil, fmt.Errorf("reward value must be positive for %s type", req.RewardType) return nil, fmt.Errorf("reward value must be positive for %s type", req.RewardType)
} }
@@ -247,7 +247,7 @@ func (s *campaignService) UpdateCampaignRule(ctx context.Context, req *contract.
} }
// Validate reward value based on reward type // Validate reward value based on reward type
if req.RewardType == "POINTS" || req.RewardType == "COINS" || req.RewardType == "TOKENS" { if req.RewardType == "POINTS" || req.RewardType == "COINS" {
if req.RewardValue == nil || *req.RewardValue <= 0 { if req.RewardValue == nil || *req.RewardValue <= 0 {
return nil, fmt.Errorf("reward value must be positive for %s type", req.RewardType) return nil, fmt.Errorf("reward value must be positive for %s type", req.RewardType)
} }
@@ -295,7 +295,7 @@ func (s *campaignService) GetCampaignRulesByCampaignID(ctx context.Context, camp
} }
func (s *campaignService) validateCampaignType(campaignType string) error { func (s *campaignService) validateCampaignType(campaignType string) error {
validTypes := []string{"REWARD", "POINTS", "COINS", "TOKENS", "MIXED"} validTypes := []string{"REWARD", "POINTS", "COINS", "MIXED"}
for _, validType := range validTypes { for _, validType := range validTypes {
if campaignType == validType { if campaignType == validType {
return nil return nil
@@ -335,7 +335,7 @@ func (s *campaignService) validateCampaignRules(rules []contract.CampaignRuleStr
} }
// Validate reward value based on reward type // Validate reward value based on reward type
if rule.RewardType == "POINTS" || rule.RewardType == "COINS" || rule.RewardType == "TOKENS" { if rule.RewardType == "POINTS" || rule.RewardType == "COINS" {
if rule.RewardValue == nil || *rule.RewardValue <= 0 { if rule.RewardValue == nil || *rule.RewardValue <= 0 {
return fmt.Errorf("reward value must be positive for %s type in rule %d", rule.RewardType, i+1) return fmt.Errorf("reward value must be positive for %s type in rule %d", rule.RewardType, i+1)
} }
@@ -365,7 +365,7 @@ func (s *campaignService) validateRuleType(ruleType string) error {
} }
func (s *campaignService) validateRewardType(rewardType string) error { func (s *campaignService) validateRewardType(rewardType string) error {
validTypes := []string{"POINTS", "COINS", "TOKENS", "REWARD"} validTypes := []string{"POINTS", "COINS", "REWARD"}
for _, validType := range validTypes { for _, validType := range validTypes {
if rewardType == validType { if rewardType == validType {
return nil return nil
@@ -10,7 +10,6 @@ import (
type CustomerPointsService interface { type CustomerPointsService interface {
GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error)
GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error)
GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error)
GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error)
GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error)
@@ -41,19 +40,6 @@ func (s *customerPointsService) GetCustomerPoints(ctx context.Context, customerI
return response, nil return response, nil
} }
func (s *customerPointsService) GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) {
if customerID == "" {
return nil, fmt.Errorf("customer ID is required")
}
response, err := s.customerPointsProcessor.GetCustomerTotalTokensAPI(ctx, customerID)
if err != nil {
return nil, fmt.Errorf("failed to get customer tokens: %w", err)
}
return response, nil
}
func (s *customerPointsService) GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) { func (s *customerPointsService) GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) {
if customerID == "" { if customerID == "" {
return nil, fmt.Errorf("customer ID is required") return nil, fmt.Errorf("customer ID is required")
@@ -276,7 +276,6 @@ func GamePlayModelToResponse(model *models.GamePlayResponse) *contract.GamePlayR
CustomerID: model.CustomerID, CustomerID: model.CustomerID,
PrizeID: model.PrizeID, PrizeID: model.PrizeID,
CoinsUsed: model.CoinsUsed, CoinsUsed: model.CoinsUsed,
TokenUsed: model.CoinsUsed,
RandomSeed: model.RandomSeed, RandomSeed: model.RandomSeed,
CreatedAt: model.CreatedAt, CreatedAt: model.CreatedAt,
Game: game, Game: game,
@@ -306,10 +305,9 @@ func PlayGameModelToResponse(model *models.PlayGameResponse) *contract.PlayGameR
} }
return &contract.PlayGameResponse{ return &contract.PlayGameResponse{
GamePlay: gamePlayValue, GamePlay: gamePlayValue,
PrizeWon: prizeWon, PrizeWon: prizeWon,
CoinsRemaining: model.CoinsRemaining, CoinsRemaining: model.CoinsRemaining,
TokensRemaining: model.CoinsRemaining,
} }
} }
@@ -27,6 +27,7 @@ func CreateOrderContractToModel(req *contract.CreateOrderRequest) *models.Create
return &models.CreateOrderRequest{ return &models.CreateOrderRequest{
OutletID: req.OutletID, OutletID: req.OutletID,
UserID: req.UserID, UserID: req.UserID,
CustomerID: req.CustomerID,
TableID: req.TableID, TableID: req.TableID,
TableNumber: req.TableNumber, TableNumber: req.TableNumber,
OrderType: constants.OrderType(req.OrderType), OrderType: constants.OrderType(req.OrderType),
@@ -32,6 +32,20 @@ func TestCreateOrderContractToModelCarriesWeight(t *testing.T) {
require.Equal(t, weight, *result.OrderItems[0].Weight) require.Equal(t, weight, *result.OrderItems[0].Weight)
} }
func TestCreateOrderContractToModelCarriesCustomer(t *testing.T) {
customerID := uuid.New()
result := CreateOrderContractToModel(&contract.CreateOrderRequest{
OutletID: uuid.New(),
UserID: uuid.New(),
CustomerID: &customerID,
OrderItems: []contract.CreateOrderItemRequest{{ProductID: uuid.New(), Quantity: 1}},
})
require.NotNil(t, result.CustomerID, "the order's customer is who earns EnakPoint and EnakCoin")
require.Equal(t, customerID, *result.CustomerID)
}
func TestAddToOrderContractToModelCarriesWeight(t *testing.T) { func TestAddToOrderContractToModelCarriesWeight(t *testing.T) {
weight := 0.8 weight := 0.8
+3 -3
View File
@@ -254,7 +254,7 @@ func (v *CampaignValidatorImpl) ValidateDeleteCampaignRuleRequest(req *contract.
} }
func (v *CampaignValidatorImpl) isValidCampaignType(campaignType string) bool { func (v *CampaignValidatorImpl) isValidCampaignType(campaignType string) bool {
validTypes := []string{"REWARD", "POINTS", "COINS", "TOKENS", "MIXED"} validTypes := []string{"REWARD", "POINTS", "COINS", "MIXED"}
return contains(validTypes, campaignType) return contains(validTypes, campaignType)
} }
@@ -264,7 +264,7 @@ func (v *CampaignValidatorImpl) isValidRuleType(ruleType string) bool {
} }
func (v *CampaignValidatorImpl) isValidRewardType(rewardType string) bool { func (v *CampaignValidatorImpl) isValidRewardType(rewardType string) bool {
validTypes := []string{"POINTS", "COINS", "TOKENS", "REWARD"} validTypes := []string{"POINTS", "COINS", "REWARD"}
return contains(validTypes, rewardType) return contains(validTypes, rewardType)
} }
@@ -297,7 +297,7 @@ func (v *CampaignValidatorImpl) validateCampaignRule(rule *contract.CampaignRule
} }
// Validate reward value based on reward type // Validate reward value based on reward type
if rule.RewardType == "POINTS" || rule.RewardType == "COINS" || rule.RewardType == "TOKENS" { if rule.RewardType == "POINTS" || rule.RewardType == "COINS" {
if rule.RewardValue == nil || *rule.RewardValue <= 0 { if rule.RewardValue == nil || *rule.RewardValue <= 0 {
return errors.New("reward value must be positive for " + rule.RewardType + " type in rule " + string(rune(ruleNumber))) return errors.New("reward value must be positive for " + rule.RewardType + " type in rule " + string(rune(ruleNumber)))
} }
@@ -13,4 +13,4 @@ DROP INDEX IF EXISTS uq_payment_methods_point_per_organization;
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check; ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
CHECK (type IN ('cash', 'card', 'digital_wallet')); CHECK (type IN ('cash', 'card', 'digital_wallet', 'qr', 'edc', 'delivery'));
@@ -1,10 +1,11 @@
-- Paying with EnakPoint (docs/prd-point-coin.md F9, §8, §10.5). -- Paying with EnakPoint (docs/prd-point-coin.md F9, §8, §10.5).
-- A new payment method type. Every organization has exactly one method of it, made by -- A new payment method type, next to the existing ones (edc and delivery were added to
-- the constraint outside the migrations). Every organization has exactly one method of it, made by
-- the system, which cannot be deleted or change type. -- the system, which cannot be deleted or change type.
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check; ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
CHECK (type IN ('cash', 'card', 'digital_wallet', 'point')); CHECK (type IN ('cash', 'card', 'digital_wallet', 'qr', 'edc', 'delivery', 'point'));
CREATE UNIQUE INDEX uq_payment_methods_point_per_organization ON payment_methods(organization_id) CREATE UNIQUE INDEX uq_payment_methods_point_per_organization ON payment_methods(organization_id)
WHERE type = 'point'; WHERE type = 'point';
@@ -0,0 +1,2 @@
-- Nothing to undo: 000094 now allows the same types.
SELECT 1;
@@ -0,0 +1,7 @@
-- Production also has edc and delivery payment methods, allowed by a constraint changed
-- outside the migrations, and the code also accepts qr, which no constraint allowed.
-- 000094 first rewrote the constraint without them, which
-- failed on production and, where it ran, dropped them. Keep every type in use.
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
CHECK (type IN ('cash', 'card', 'digital_wallet', 'qr', 'edc', 'delivery', 'point'));