feat(loyalty): EnakPoint & EnakCoin #32

Merged
aefril merged 36 commits from feature/point-coint into main 2026-09-30 10:03:08 +02:00
15 changed files with 652 additions and 35 deletions
Showing only changes of commit 41b75810fd - Show all commits
+8
View File
@@ -42,6 +42,7 @@ help:
@echo " migration-up Up migrations" @echo " migration-up Up migrations"
@echo " migration-up ENV=production Up migrations (production DB)" @echo " migration-up ENV=production Up migrations (production DB)"
@echo " migration-down Down last migration" @echo " migration-down Down last migration"
@echo " wallet-migrate args=-dry-run Move legacy points/tokens into the wallet"
@echo @echo
@echo " docker-up Up docker services" @echo " docker-up Up docker services"
@echo " docker-down Down docker services" @echo " docker-down Down docker services"
@@ -105,6 +106,13 @@ migration-down:
migration-force: migration-force:
@migrate -database $(DB_URL) -path ./migrations force $(version) @migrate -database $(DB_URL) -path ./migrations force $(version)
# Move customer_points / customer_tokens into the wallet. Safe to run again.
# Pass args=-dry-run to only report.
.SILENT: wallet-migrate
wallet-migrate:
ENV_MODE=$(ENV) go run ./cmd/wallet-migrate $(args)
.SILENT: seeder-create .SILENT: seeder-create
seeder-create: seeder-create:
@migrate create -ext sql -dir ./seeders -seq $(name) @migrate create -ext sql -dir ./seeders -seq $(name)
+73
View File
@@ -0,0 +1,73 @@
// Command wallet-migrate moves customer_points and customer_tokens balances into the
// EnakPoint & EnakCoin wallet (docs/prd-point-coin.md §10). Safe to run more than once:
// each run credits only what earlier runs have not.
//
// ENV_MODE=staging go run ./cmd/wallet-migrate -dry-run
// ENV_MODE=staging go run ./cmd/wallet-migrate
//
// It exits non-zero when the legacy totals and the migrated totals disagree.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"apskel-pos-be/config"
"apskel-pos-be/internal/db"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
)
func main() {
dryRun := flag.Bool("dry-run", false, "report what would be credited without writing anything")
batchSize := flag.Int("batch", 500, "customers read per batch")
flag.Parse()
cfg := config.LoadConfig()
pg, err := db.NewPostgres(cfg.Database)
if err != nil {
log.Fatal(err)
}
migrator := processor.NewWalletMigrationProcessor(
repository.NewWalletMigrationRepository(pg),
processor.NewWalletProcessor(repository.NewWalletRepository(pg)),
repository.NewTxManager(pg),
)
report, err := migrator.Run(context.Background(), *dryRun, *batchSize)
if err != nil {
log.Fatalf("wallet migration failed: %v", err)
}
verb := "credited"
if report.DryRun {
verb = "would credit"
}
t := report.Totals
fmt.Printf("customers scanned: %d\n", report.CustomersScanned)
fmt.Printf("EnakPoint: %s %d in %d rows\n", verb, report.PointsCredited, report.PointCredits)
fmt.Printf("EnakCoin: %s %d in %d rows\n", verb, report.CoinsCredited, report.CoinCredits)
fmt.Printf("\n%-10s %15s %15s %15s\n", "", "legacy", "migrated", "wallet balance")
fmt.Printf("%-10s %15d %15d %15d\n", "EnakPoint", t.LegacyPoints, t.MigratedPoints, t.WalletPoints)
fmt.Printf("%-10s %15d %15d %15d\n", "EnakCoin", t.LegacyCoins, t.MigratedCoins, t.WalletCoins)
if len(report.Discrepancies) > 0 {
fmt.Printf("\n%d balances shrank in the legacy tables after they were migrated.\n", len(report.Discrepancies))
fmt.Println("They were left as they are in the wallet; correct them with an adjustment if needed:")
for _, d := range report.Discrepancies {
fmt.Printf(" customer %s %s: legacy %d, migrated %d\n", d.CustomerID, d.Currency, d.Legacy, d.Migrated)
}
}
if report.DryRun {
return
}
if !report.Balanced() {
fmt.Println("\nNOT BALANCED: legacy and migrated totals differ")
os.Exit(1)
}
fmt.Println("\nbalanced: legacy totals = migrated totals")
}
+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 TOKENS MIXED"` Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS 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 TOKENS MIXED"` Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS 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 TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS 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 TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS 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 TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS 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"`
+23 -2
View File
@@ -12,10 +12,31 @@ type CampaignType string
const ( const (
CampaignTypeReward CampaignType = "REWARD" CampaignTypeReward CampaignType = "REWARD"
CampaignTypePoints CampaignType = "POINTS" CampaignTypePoints CampaignType = "POINTS"
CampaignTypeTokens CampaignType = "TOKENS" CampaignTypeCoins CampaignType = "COINS"
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 (
@@ -31,7 +52,7 @@ type CampaignRewardType string
const ( const (
CampaignRewardTypePoints CampaignRewardType = "POINTS" CampaignRewardTypePoints CampaignRewardType = "POINTS"
CampaignRewardTypeTokens CampaignRewardType = "TOKENS" CampaignRewardTypeCoins CampaignRewardType = "COINS"
CampaignRewardTypeReward CampaignRewardType = "REWARD" CampaignRewardTypeReward CampaignRewardType = "REWARD"
) )
+18
View File
@@ -0,0 +1,18 @@
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(""))
}
+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.CampaignType(request.Type), Type: entities.NormalizeCampaignType(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.CampaignType(request.Type), Type: entities.NormalizeCampaignType(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.CampaignRewardType(request.RewardType), RewardType: entities.NormalizeCampaignRewardType(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.CampaignRewardType(request.RewardType), RewardType: entities.NormalizeCampaignRewardType(request.RewardType),
RewardValue: request.RewardValue, RewardValue: request.RewardValue,
RewardSubtype: rewardSubtype, RewardSubtype: rewardSubtype,
RewardRefID: request.RewardRefID, RewardRefID: request.RewardRefID,
+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 TOKENS MIXED"` Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS 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 TOKENS MIXED"` Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS 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 TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS 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 TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS 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 TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS 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"`
+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: req.Type, Type: string(entities.NormalizeCampaignType(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.CampaignRewardType(req.RewardType), RewardType: entities.NormalizeCampaignRewardType(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: req.RewardType, RewardType: string(entities.NormalizeCampaignRewardType(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.CampaignRewardType(req.RewardType), RewardType: entities.NormalizeCampaignRewardType(req.RewardType),
RewardValue: req.RewardValue, RewardValue: req.RewardValue,
RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype), RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype),
RewardRefID: req.RewardRefID, RewardRefID: req.RewardRefID,
@@ -0,0 +1,184 @@
package processor
import (
"context"
"fmt"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/repository"
)
// TxRunner runs fn inside a database transaction. repository.TxManager is one.
type TxRunner interface {
WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error
}
// WalletMigrationDiscrepancy is a customer whose legacy balance is now lower than
// what was already migrated: the old code spent from it after the migration ran.
// The wallet is left alone, because only an admin adjustment can take balance away.
type WalletMigrationDiscrepancy struct {
CustomerID uuid.UUID
Currency string
Legacy int64
Migrated int64
}
type WalletMigrationReport struct {
DryRun bool
CustomersScanned int
// Ledger rows written (or, on a dry run, that would be written) and their sum.
PointCredits int
PointsCredited int64
CoinCredits int
CoinsCredited int64
Discrepancies []WalletMigrationDiscrepancy
// Taken after the run. On a dry run they show the state before it.
Totals *repository.WalletMigrationTotals
}
// Balanced reports whether everything in the legacy tables is now in the wallet.
func (r *WalletMigrationReport) Balanced() bool {
return len(r.Discrepancies) == 0 && r.Totals != nil &&
r.Totals.LegacyPoints == r.Totals.MigratedPoints &&
r.Totals.LegacyCoins == r.Totals.MigratedCoins
}
// WalletMigrationProcessor moves the balances in customer_points and customer_tokens
// into the wallet (docs/prd-point-coin.md §10, PC-105). Each customer gets a MIGRATION
// ledger row and a non-expiring lot per currency, through WalletProcessor like any
// other credit, so the wallet reconciles from the first row.
//
// It credits the difference between the legacy balance and what earlier runs already
// migrated, so running it again never doubles a balance, and a run after the old code
// kept writing to the legacy tables picks up only what was added since.
type WalletMigrationProcessor struct {
repo repository.WalletMigrationRepository
wallet *WalletProcessor
tx TxRunner
}
func NewWalletMigrationProcessor(repo repository.WalletMigrationRepository, wallet *WalletProcessor, tx TxRunner) *WalletMigrationProcessor {
return &WalletMigrationProcessor{repo: repo, wallet: wallet, tx: tx}
}
// Run migrates every customer with a legacy balance, one transaction per customer.
// With dryRun it only reports what it would credit.
func (p *WalletMigrationProcessor) Run(ctx context.Context, dryRun bool, batchSize int) (*WalletMigrationReport, error) {
if batchSize <= 0 {
batchSize = 500
}
report := &WalletMigrationReport{DryRun: dryRun}
after := uuid.Nil
for {
ids, err := p.repo.ListLegacyCustomers(ctx, after, batchSize)
if err != nil {
return nil, err
}
if len(ids) == 0 {
break
}
for _, id := range ids {
if dryRun {
err = p.migrateCustomer(ctx, id, true, report)
} else {
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
return p.migrateCustomer(ctx, id, false, report)
})
}
if err != nil {
return nil, fmt.Errorf("customer %s: %w", id, err)
}
report.CustomersScanned++
}
after = ids[len(ids)-1]
}
totals, err := p.repo.Totals(ctx)
if err != nil {
return nil, err
}
report.Totals = totals
return report, nil
}
func (p *WalletMigrationProcessor) migrateCustomer(ctx context.Context, customerID uuid.UUID, dryRun bool, report *WalletMigrationReport) error {
// Lock before reading what was migrated, so two runs at once cannot both see the
// same gap and fill it twice.
if !dryRun {
if err := p.wallet.LockWallet(ctx, customerID); err != nil {
return err
}
}
legacy, err := p.repo.GetLegacyBalance(ctx, customerID)
if err != nil {
return err
}
// Points come from the single customer_points row. Tokens come from several rows,
// one per type, so the ledger row points at the customer and lists the rows.
pointsRef := customerID
if legacy.PointsRowID != nil {
pointsRef = *legacy.PointsRowID
}
tokens := make([]map[string]any, 0, len(legacy.Tokens))
for _, t := range legacy.Tokens {
tokens = append(tokens, map[string]any{"id": t.ID, "token_type": string(t.TokenType), "balance": t.Balance})
}
for _, c := range []struct {
currency, refType string
refID uuid.UUID
legacy int64
metadata entities.Metadata
credits *int
credited *int64
}{
{constants.WalletCurrencyPoint, constants.WalletRefTypeLegacyPoints, pointsRef, legacy.Points,
entities.Metadata{}, &report.PointCredits, &report.PointsCredited},
{constants.WalletCurrencyCoin, constants.WalletRefTypeLegacyTokens, customerID, legacy.Coins(),
entities.Metadata{"legacy_tokens": tokens}, &report.CoinCredits, &report.CoinsCredited},
} {
migrated, err := p.repo.SumMigrated(ctx, customerID, c.currency)
if err != nil {
return err
}
delta := c.legacy - migrated
if delta < 0 {
report.Discrepancies = append(report.Discrepancies, WalletMigrationDiscrepancy{
CustomerID: customerID, Currency: c.currency, Legacy: c.legacy, Migrated: migrated,
})
continue
}
if delta == 0 {
continue
}
if !dryRun {
c.metadata["legacy_balance"] = c.legacy
c.metadata["previously_migrated"] = migrated
_, err = p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
CustomerID: customerID,
Currency: c.currency,
Type: constants.WalletTxTypeMigration,
Amount: delta,
ReferenceType: c.refType,
ReferenceID: c.refID,
Description: "Saldo awal dari sistem lama",
Metadata: c.metadata,
// The legacy total in the key lets a later run top up a balance that
// grew, while a retry of the same run is still recognised.
IdempotencyKey: fmt.Sprintf("migration:%s:%s:%d", c.currency, customerID, c.legacy),
}})
if err != nil {
return err
}
}
*c.credits++
*c.credited += delta
}
return nil
}
@@ -0,0 +1,167 @@
package processor
import (
"context"
"os"
"testing"
"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/constants"
"apskel-pos-be/internal/repository"
)
// Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go. Other packages' tests may use the
// same database at the same time, so everything here is scoped to its own customers.
func TestWalletMigrationProcessor_AgainstPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
ctx := context.Background()
org := uuid.New()
full, tokensOnly, pointsOnly, none := uuid.New(), uuid.New(), uuid.New(), uuid.New()
customers := []uuid.UUID{full, tokensOnly, pointsOnly, none}
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 (?, 'migration test', 'basic')`, org)
for _, c := range customers {
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'migration test')`, c, org)
}
t.Cleanup(func() {
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
// The example from §10: SPIN 5 + RAFFLE 2 + MINIGAME 1 = 8 EnakCoin.
exec(`INSERT INTO customer_points (customer_id, balance) VALUES (?, 100), (?, 0), (?, 40)`, full, tokensOnly, pointsOnly)
exec(`INSERT INTO customer_tokens (customer_id, token_type, balance) VALUES
(?, 'SPIN', 5), (?, 'RAFFLE', 2), (?, 'MINIGAME', 1), (?, 'SPIN', 3)`, full, full, full, tokensOnly)
migrator := NewWalletMigrationProcessor(
repository.NewWalletMigrationRepository(db),
NewWalletProcessor(repository.NewWalletRepository(db)),
repository.NewTxManager(db),
)
type balance struct{ Point, Coin int64 }
balances := func() map[uuid.UUID]balance {
t.Helper()
var rows []struct {
CustomerID uuid.UUID
PointBalance, CoinBalance int64
}
require.NoError(t, db.Raw(`SELECT customer_id, point_balance, coin_balance FROM customer_wallets WHERE customer_id IN ?`, customers).Scan(&rows).Error)
out := map[uuid.UUID]balance{}
for _, r := range rows {
out[r.CustomerID] = balance{r.PointBalance, r.CoinBalance}
}
return out
}
countRows := func() int64 {
t.Helper()
var n int64
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id IN ?`, customers).Scan(&n).Error)
return n
}
// A dry run reports and writes nothing, not even the wallets.
report, err := migrator.Run(ctx, true, 2)
require.NoError(t, err)
assert.GreaterOrEqual(t, report.PointsCredited, int64(140))
assert.GreaterOrEqual(t, report.CoinsCredited, int64(11))
assert.Empty(t, balances())
assert.Zero(t, countRows())
// The real run. A batch of 2 makes it page through the customers.
_, err = migrator.Run(ctx, false, 2)
require.NoError(t, err)
assert.Equal(t, map[uuid.UUID]balance{
full: {Point: 100, Coin: 8},
tokensOnly: {Point: 0, Coin: 3},
pointsOnly: {Point: 40, Coin: 0},
}, balances(), "a customer without legacy rows gets no wallet")
assert.Equal(t, int64(4), countRows(), "one row per customer per currency with a balance")
var coinRow struct {
ReferenceType string
ReferenceID uuid.UUID
Metadata string
}
require.NoError(t, db.Raw(`SELECT reference_type, reference_id, metadata::text AS metadata FROM wallet_transactions
WHERE customer_id = ? AND currency = 'COIN'`, full).Scan(&coinRow).Error)
assert.Equal(t, constants.WalletRefTypeLegacyTokens, coinRow.ReferenceType)
assert.Equal(t, full, coinRow.ReferenceID)
for _, part := range []string{`"token_type": "SPIN"`, `"token_type": "RAFFLE"`, `"token_type": "MINIGAME"`, `"legacy_balance": 8`} {
assert.Contains(t, coinRow.Metadata, part)
}
var pointRef, pointsRowID string
require.NoError(t, db.Raw(`SELECT reference_id::text FROM wallet_transactions WHERE customer_id = ? AND currency = 'POINT'`, full).Scan(&pointRef).Error)
require.NoError(t, db.Raw(`SELECT id::text FROM customer_points WHERE customer_id = ?`, full).Scan(&pointsRowID).Error)
assert.NotEmpty(t, pointRef)
assert.Equal(t, pointsRowID, pointRef, "points row points at the customer_points row")
var expiring int64
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_lots WHERE customer_id IN ? AND expires_at IS NOT NULL`, customers).Scan(&expiring).Error)
assert.Zero(t, expiring, "migrated lots never expire")
// Running again changes nothing.
report, err = migrator.Run(ctx, false, 2)
require.NoError(t, err)
assert.Equal(t, int64(4), countRows())
assert.Empty(t, discrepanciesFor(report, customers))
// The old code kept writing: one balance grew, one shrank. Only the growth is
// migrated; the shrink is reported and left alone.
exec(`UPDATE customer_tokens SET balance = 9 WHERE customer_id = ? AND token_type = 'SPIN'`, full)
exec(`UPDATE customer_points SET balance = 30 WHERE customer_id = ?`, pointsOnly)
report, err = migrator.Run(ctx, false, 2)
require.NoError(t, err)
assert.Equal(t, balance{Point: 100, Coin: 12}, balances()[full])
assert.Equal(t, balance{Point: 40, Coin: 0}, balances()[pointsOnly])
assert.Equal(t, []WalletMigrationDiscrepancy{{CustomerID: pointsOnly, Currency: constants.WalletCurrencyPoint, Legacy: 30, Migrated: 40}},
discrepanciesFor(report, customers))
assert.Equal(t, int64(5), countRows())
// §7.5 for these customers.
var broken int64
require.NoError(t, db.Raw(`
SELECT COUNT(*) FROM customer_wallets w
WHERE w.customer_id IN ? AND (
w.point_balance <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions t WHERE t.customer_id = w.customer_id AND t.currency = 'POINT')
OR w.coin_balance <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions t WHERE t.customer_id = w.customer_id AND t.currency = 'COIN')
OR w.point_balance <> (SELECT COALESCE(SUM(remaining_amount), 0) FROM wallet_lots l WHERE l.customer_id = w.customer_id AND l.currency = 'POINT')
OR w.coin_balance <> (SELECT COALESCE(SUM(remaining_amount), 0) FROM wallet_lots l WHERE l.customer_id = w.customer_id AND l.currency = 'COIN'))`,
customers).Scan(&broken).Error)
assert.Zero(t, broken)
}
func discrepanciesFor(report *WalletMigrationReport, customers []uuid.UUID) []WalletMigrationDiscrepancy {
mine := map[uuid.UUID]bool{}
for _, c := range customers {
mine[c] = true
}
var out []WalletMigrationDiscrepancy
for _, d := range report.Discrepancies {
if mine[d.CustomerID] {
out = append(out, d)
}
}
return out
}
@@ -0,0 +1,134 @@
package repository
import (
"context"
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
// LegacyBalance is what one customer holds in customer_points and customer_tokens,
// the tables the wallet replaces (docs/prd-point-coin.md §10).
type LegacyBalance struct {
CustomerID uuid.UUID
// Nil when the customer has no customer_points row.
PointsRowID *uuid.UUID
Points int64
Tokens []entities.CustomerTokens
}
// Coins is the sum of every token type: all of them become EnakCoin (Q6).
func (b LegacyBalance) Coins() int64 {
var total int64
for _, t := range b.Tokens {
total += t.Balance
}
return total
}
// WalletMigrationTotals compares the legacy tables with what has been migrated.
type WalletMigrationTotals struct {
LegacyPoints int64
LegacyCoins int64
MigratedPoints int64
MigratedCoins int64
WalletPoints int64
WalletCoins int64
}
// WalletMigrationRepository reads the legacy balances for the one-time move into the
// wallet. The writes go through the wallet processor like any other credit.
type WalletMigrationRepository interface {
// ListLegacyCustomers returns, in id order, up to limit customers after the given
// id that have a row in customer_points or customer_tokens.
ListLegacyCustomers(ctx context.Context, after uuid.UUID, limit int) ([]uuid.UUID, error)
GetLegacyBalance(ctx context.Context, customerID uuid.UUID) (*LegacyBalance, error)
// SumMigrated returns how much has already been credited to the customer by
// MIGRATION ledger rows in the currency.
SumMigrated(ctx context.Context, customerID uuid.UUID, currency string) (int64, error)
Totals(ctx context.Context) (*WalletMigrationTotals, error)
}
type walletMigrationRepository struct {
db *gorm.DB
}
func NewWalletMigrationRepository(db *gorm.DB) WalletMigrationRepository {
return &walletMigrationRepository{db: db}
}
func (r *walletMigrationRepository) ListLegacyCustomers(ctx context.Context, after uuid.UUID, limit int) ([]uuid.UUID, error) {
var ids []uuid.UUID
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT customer_id FROM (
SELECT customer_id FROM customer_points
UNION
SELECT customer_id FROM customer_tokens
) legacy
WHERE customer_id > ?
ORDER BY customer_id
LIMIT ?`, after, limit).
Scan(&ids).Error
if err != nil {
return nil, fmt.Errorf("failed to list legacy customers: %w", err)
}
return ids, nil
}
func (r *walletMigrationRepository) GetLegacyBalance(ctx context.Context, customerID uuid.UUID) (*LegacyBalance, error) {
db := DBFromContext(ctx, r.db).WithContext(ctx)
balance := &LegacyBalance{CustomerID: customerID}
// Find rather than First: many customers have tokens but no points row, and First
// would log each of them as a "record not found" error.
var points []entities.CustomerPoints
if err := db.Where("customer_id = ?", customerID).Limit(1).Find(&points).Error; err != nil {
return nil, fmt.Errorf("failed to get legacy points: %w", err)
}
if len(points) > 0 {
balance.PointsRowID = &points[0].ID
balance.Points = points[0].Balance
}
err := db.Where("customer_id = ?", customerID).Order("token_type").Find(&balance.Tokens).Error
if err != nil {
return nil, fmt.Errorf("failed to get legacy tokens: %w", err)
}
return balance, nil
}
func (r *walletMigrationRepository) SumMigrated(ctx context.Context, customerID uuid.UUID, currency string) (int64, error) {
var total int64
err := DBFromContext(ctx, r.db).WithContext(ctx).
Model(&entities.WalletTransaction{}).
Where("customer_id = ? AND currency = ? AND type = ?", customerID, currency, constants.WalletTxTypeMigration).
Select("COALESCE(SUM(amount), 0)").
Scan(&total).Error
if err != nil {
return 0, fmt.Errorf("failed to sum migrated balance: %w", err)
}
return total, nil
}
func (r *walletMigrationRepository) Totals(ctx context.Context) (*WalletMigrationTotals, error) {
var totals WalletMigrationTotals
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT
(SELECT COALESCE(SUM(balance), 0) FROM customer_points) AS legacy_points,
(SELECT COALESCE(SUM(balance), 0) FROM customer_tokens) AS legacy_coins,
(SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE type = ? AND currency = ?) AS migrated_points,
(SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE type = ? AND currency = ?) AS migrated_coins,
(SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets) AS wallet_points,
(SELECT COALESCE(SUM(coin_balance), 0) FROM customer_wallets) AS wallet_coins`,
constants.WalletTxTypeMigration, constants.WalletCurrencyPoint,
constants.WalletTxTypeMigration, constants.WalletCurrencyCoin).
Scan(&totals).Error
if err != nil {
return nil, fmt.Errorf("failed to compute wallet migration totals: %w", err)
}
return &totals, nil
}
+7 -7
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 == "TOKENS" { if req.RewardType == "POINTS" || req.RewardType == "COINS" || req.RewardType == "TOKENS" {
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 == "TOKENS" { if req.RewardType == "POINTS" || req.RewardType == "COINS" || req.RewardType == "TOKENS" {
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,13 +295,13 @@ 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", "TOKENS", "MIXED"} validTypes := []string{"REWARD", "POINTS", "COINS", "TOKENS", "MIXED"}
for _, validType := range validTypes { for _, validType := range validTypes {
if campaignType == validType { if campaignType == validType {
return nil return nil
} }
} }
return fmt.Errorf("invalid campaign type: %s. Valid types are: REWARD, POINTS, TOKENS, MIXED", campaignType) return fmt.Errorf("invalid campaign type: %s. Valid types are: REWARD, POINTS, COINS, MIXED", campaignType)
} }
func (s *campaignService) validateDateRange(startDate, endDate time.Time) error { func (s *campaignService) validateDateRange(startDate, endDate time.Time) error {
@@ -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 == "TOKENS" { if rule.RewardType == "POINTS" || rule.RewardType == "COINS" || rule.RewardType == "TOKENS" {
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,11 +365,11 @@ func (s *campaignService) validateRuleType(ruleType string) error {
} }
func (s *campaignService) validateRewardType(rewardType string) error { func (s *campaignService) validateRewardType(rewardType string) error {
validTypes := []string{"POINTS", "TOKENS", "REWARD"} validTypes := []string{"POINTS", "COINS", "TOKENS", "REWARD"}
for _, validType := range validTypes { for _, validType := range validTypes {
if rewardType == validType { if rewardType == validType {
return nil return nil
} }
} }
return fmt.Errorf("invalid reward type: %s. Valid types are: POINTS, TOKENS, REWARD", rewardType) return fmt.Errorf("invalid reward type: %s. Valid types are: POINTS, COINS, REWARD", rewardType)
} }
+8 -8
View File
@@ -43,7 +43,7 @@ func (v *CampaignValidatorImpl) ValidateCreateCampaignRequest(req *contract.Crea
// Validate campaign type // Validate campaign type
if !v.isValidCampaignType(req.Type) { if !v.isValidCampaignType(req.Type) {
return errors.New("invalid campaign type. Valid types are: REWARD, POINTS, TOKENS, MIXED"), constants.ValidationErrorCode return errors.New("invalid campaign type. Valid types are: REWARD, POINTS, COINS, MIXED"), constants.ValidationErrorCode
} }
// Validate date range // Validate date range
@@ -79,7 +79,7 @@ func (v *CampaignValidatorImpl) ValidateUpdateCampaignRequest(req *contract.Upda
// Validate campaign type // Validate campaign type
if !v.isValidCampaignType(req.Type) { if !v.isValidCampaignType(req.Type) {
return errors.New("invalid campaign type. Valid types are: REWARD, POINTS, TOKENS, MIXED"), constants.ValidationErrorCode return errors.New("invalid campaign type. Valid types are: REWARD, POINTS, COINS, MIXED"), constants.ValidationErrorCode
} }
// Validate date range // Validate date range
@@ -113,7 +113,7 @@ func (v *CampaignValidatorImpl) ValidateListCampaignsRequest(req *contract.ListC
// Validate campaign type filter if provided // Validate campaign type filter if provided
if req.Type != "" && !v.isValidCampaignType(req.Type) { if req.Type != "" && !v.isValidCampaignType(req.Type) {
return errors.New("invalid campaign type filter. Valid types are: REWARD, POINTS, TOKENS, MIXED"), constants.ValidationErrorCode return errors.New("invalid campaign type filter. Valid types are: REWARD, POINTS, COINS, MIXED"), constants.ValidationErrorCode
} }
return nil, "" return nil, ""
@@ -223,7 +223,7 @@ func (v *CampaignValidatorImpl) ValidateListCampaignRulesRequest(req *contract.L
// Validate reward type filter if provided // Validate reward type filter if provided
if req.RewardType != "" && !v.isValidRewardType(req.RewardType) { if req.RewardType != "" && !v.isValidRewardType(req.RewardType) {
return errors.New("invalid reward type filter. Valid types are: POINTS, TOKENS, REWARD"), constants.ValidationErrorCode return errors.New("invalid reward type filter. Valid types are: POINTS, COINS, REWARD"), constants.ValidationErrorCode
} }
return nil, "" return nil, ""
@@ -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", "TOKENS", "MIXED"} validTypes := []string{"REWARD", "POINTS", "COINS", "TOKENS", "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", "TOKENS", "REWARD"} validTypes := []string{"POINTS", "COINS", "TOKENS", "REWARD"}
return contains(validTypes, rewardType) return contains(validTypes, rewardType)
} }
@@ -293,11 +293,11 @@ func (v *CampaignValidatorImpl) validateCampaignRule(rule *contract.CampaignRule
// Validate reward type // Validate reward type
if !v.isValidRewardType(rule.RewardType) { if !v.isValidRewardType(rule.RewardType) {
return errors.New("invalid reward type in rule " + string(rune(ruleNumber)) + ". Valid types are: POINTS, TOKENS, REWARD") return errors.New("invalid reward type in rule " + string(rune(ruleNumber)) + ". Valid types are: POINTS, COINS, REWARD")
} }
// Validate reward value based on reward type // Validate reward value based on reward type
if rule.RewardType == "POINTS" || rule.RewardType == "TOKENS" { if rule.RewardType == "POINTS" || rule.RewardType == "COINS" || rule.RewardType == "TOKENS" {
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)))
} }
@@ -0,0 +1,5 @@
UPDATE campaigns SET type = 'TOKENS' WHERE type = 'COINS';
UPDATE campaign_rules SET reward_type = 'TOKENS' WHERE reward_type = 'COINS';
COMMENT ON COLUMN campaigns.type IS 'Type of campaign: REWARD, POINTS, TOKENS, MIXED';
COMMENT ON COLUMN campaign_rules.reward_type IS 'Type of reward: POINTS, TOKENS, REWARD';
@@ -0,0 +1,7 @@
-- Tokens become EnakCoin (docs/prd-point-coin.md §10). Campaigns that handed out
-- tokens now hand out coins; the API still accepts TOKENS and stores it as COINS.
UPDATE campaigns SET type = 'COINS' WHERE type = 'TOKENS';
UPDATE campaign_rules SET reward_type = 'COINS' WHERE reward_type = 'TOKENS';
COMMENT ON COLUMN campaigns.type IS 'Type of campaign: REWARD, POINTS, COINS, MIXED';
COMMENT ON COLUMN campaign_rules.reward_type IS 'Type of reward: POINTS, COINS, REWARD';