feat(wallet): migrate legacy points and tokens into the wallet

Adds cmd/wallet-migrate (make wallet-migrate, args=-dry-run to only report),
which moves 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, written through WalletProcessor in
one transaction per customer. EnakCoin is the sum of every token type (Q6),
with the legacy rows listed in the row's metadata.

It credits the difference between the legacy balance and what earlier runs
migrated, so running it again never doubles a balance and picks up only
what the old code added since. A legacy balance that shrank after being
migrated is reported and left alone, since only an admin adjustment may
take balance away, and the command then exits non-zero. It ends with a
legacy / migrated / wallet total per currency.

Migration 000092 renames TOKENS to COINS in campaigns.type and
campaign_rules.reward_type. The campaign API now validates COINS; it still
accepts TOKENS, including as a list filter, and stores it as COINS so older
dashboards keep working while they are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 08:47:48 +07:00
co-authored by Claude Opus 5.5
parent 6af97f5696
commit 41b75810fd
15 changed files with 652 additions and 35 deletions
+5 -5
View File
@@ -12,7 +12,7 @@ import (
type CreateCampaignRequest struct {
Name string `json:"name" binding:"required,min=1,max=150"`
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"`
EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"`
@@ -25,7 +25,7 @@ type UpdateCampaignRequest struct {
ID uuid.UUID `json:"id" binding:"required"`
Name string `json:"name" binding:"required,min=1,max=150"`
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"`
EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"`
@@ -58,7 +58,7 @@ type CreateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
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"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -70,7 +70,7 @@ type UpdateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
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"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -142,7 +142,7 @@ type ListCampaignRulesResponse struct {
type CampaignRuleStruct struct {
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
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"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
+23 -2
View File
@@ -12,10 +12,31 @@ type CampaignType string
const (
CampaignTypeReward CampaignType = "REWARD"
CampaignTypePoints CampaignType = "POINTS"
CampaignTypeTokens CampaignType = "TOKENS"
CampaignTypeCoins CampaignType = "COINS"
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
const (
@@ -31,7 +52,7 @@ type CampaignRewardType string
const (
CampaignRewardTypePoints CampaignRewardType = "POINTS"
CampaignRewardTypeTokens CampaignRewardType = "TOKENS"
CampaignRewardTypeCoins CampaignRewardType = "COINS"
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{
Name: request.Name,
Description: request.Description,
Type: entities.CampaignType(request.Type),
Type: entities.NormalizeCampaignType(request.Type),
StartDate: request.StartDate,
EndDate: request.EndDate,
IsActive: request.IsActive,
@@ -91,7 +91,7 @@ func ToCampaignEntityFromUpdate(request *contract.UpdateCampaignRequest) *entiti
ID: request.ID,
Name: request.Name,
Description: request.Description,
Type: entities.CampaignType(request.Type),
Type: entities.NormalizeCampaignType(request.Type),
StartDate: request.StartDate,
EndDate: request.EndDate,
IsActive: request.IsActive,
@@ -118,7 +118,7 @@ func ToCampaignRuleEntity(request *contract.CampaignRuleStruct, campaignID uuid.
CampaignID: campaignID,
RuleType: entities.RuleType(request.RuleType),
ConditionValue: request.ConditionValue,
RewardType: entities.CampaignRewardType(request.RewardType),
RewardType: entities.NormalizeCampaignRewardType(request.RewardType),
RewardValue: request.RewardValue,
RewardSubtype: rewardSubtype,
RewardRefID: request.RewardRefID,
@@ -142,7 +142,7 @@ func ToCampaignRuleEntityFromUpdate(request *contract.CampaignRuleStruct, campai
CampaignID: campaignID,
RuleType: entities.RuleType(request.RuleType),
ConditionValue: request.ConditionValue,
RewardType: entities.CampaignRewardType(request.RewardType),
RewardType: entities.NormalizeCampaignRewardType(request.RewardType),
RewardValue: request.RewardValue,
RewardSubtype: rewardSubtype,
RewardRefID: request.RewardRefID,
+5 -5
View File
@@ -11,7 +11,7 @@ import (
type CreateCampaignRequest struct {
Name string `json:"name" binding:"required,min=1,max=150"`
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"`
EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"`
@@ -25,7 +25,7 @@ type UpdateCampaignRequest struct {
ID uuid.UUID `json:"id" binding:"required"`
Name string `json:"name" binding:"required,min=1,max=150"`
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"`
EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"`
@@ -88,7 +88,7 @@ type CreateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
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"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -100,7 +100,7 @@ type UpdateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
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"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -126,7 +126,7 @@ type ListCampaignRulesResponse struct {
type CampaignRuleStruct struct {
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
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"`
RewardSubtype *string `json:"reward_subtype,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,
Limit: req.Limit,
Search: req.Search,
Type: req.Type,
Type: string(entities.NormalizeCampaignType(req.Type)),
IsActive: req.IsActive,
ShowOnApp: req.ShowOnApp,
StartDate: req.StartDate,
@@ -178,7 +178,7 @@ func (p *campaignRuleProcessor) CreateCampaignRule(ctx context.Context, req *con
CampaignID: req.CampaignID,
RuleType: entities.RuleType(req.RuleType),
ConditionValue: req.ConditionValue,
RewardType: entities.CampaignRewardType(req.RewardType),
RewardType: entities.NormalizeCampaignRewardType(req.RewardType),
RewardValue: req.RewardValue,
RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype),
RewardRefID: req.RewardRefID,
@@ -218,7 +218,7 @@ func (p *campaignRuleProcessor) ListCampaignRules(ctx context.Context, req *cont
Limit: req.Limit,
CampaignID: req.CampaignID,
RuleType: req.RuleType,
RewardType: req.RewardType,
RewardType: string(entities.NormalizeCampaignRewardType(req.RewardType)),
}
// Get from repository
@@ -247,7 +247,7 @@ func (p *campaignRuleProcessor) UpdateCampaignRule(ctx context.Context, req *con
CampaignID: req.CampaignID,
RuleType: entities.RuleType(req.RuleType),
ConditionValue: req.ConditionValue,
RewardType: entities.CampaignRewardType(req.RewardType),
RewardType: entities.NormalizeCampaignRewardType(req.RewardType),
RewardValue: req.RewardValue,
RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype),
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
if req.RewardType == "POINTS" || req.RewardType == "TOKENS" {
if req.RewardType == "POINTS" || req.RewardType == "COINS" || req.RewardType == "TOKENS" {
if req.RewardValue == nil || *req.RewardValue <= 0 {
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
if req.RewardType == "POINTS" || req.RewardType == "TOKENS" {
if req.RewardType == "POINTS" || req.RewardType == "COINS" || req.RewardType == "TOKENS" {
if req.RewardValue == nil || *req.RewardValue <= 0 {
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 {
validTypes := []string{"REWARD", "POINTS", "TOKENS", "MIXED"}
validTypes := []string{"REWARD", "POINTS", "COINS", "TOKENS", "MIXED"}
for _, validType := range validTypes {
if campaignType == validType {
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 {
@@ -335,7 +335,7 @@ func (s *campaignService) validateCampaignRules(rules []contract.CampaignRuleStr
}
// 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 {
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 {
validTypes := []string{"POINTS", "TOKENS", "REWARD"}
validTypes := []string{"POINTS", "COINS", "TOKENS", "REWARD"}
for _, validType := range validTypes {
if rewardType == validType {
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
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
@@ -79,7 +79,7 @@ func (v *CampaignValidatorImpl) ValidateUpdateCampaignRequest(req *contract.Upda
// Validate campaign 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
@@ -113,7 +113,7 @@ func (v *CampaignValidatorImpl) ValidateListCampaignsRequest(req *contract.ListC
// Validate campaign type filter if provided
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, ""
@@ -223,7 +223,7 @@ func (v *CampaignValidatorImpl) ValidateListCampaignRulesRequest(req *contract.L
// Validate reward type filter if provided
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, ""
@@ -254,7 +254,7 @@ func (v *CampaignValidatorImpl) ValidateDeleteCampaignRuleRequest(req *contract.
}
func (v *CampaignValidatorImpl) isValidCampaignType(campaignType string) bool {
validTypes := []string{"REWARD", "POINTS", "TOKENS", "MIXED"}
validTypes := []string{"REWARD", "POINTS", "COINS", "TOKENS", "MIXED"}
return contains(validTypes, campaignType)
}
@@ -264,7 +264,7 @@ func (v *CampaignValidatorImpl) isValidRuleType(ruleType string) bool {
}
func (v *CampaignValidatorImpl) isValidRewardType(rewardType string) bool {
validTypes := []string{"POINTS", "TOKENS", "REWARD"}
validTypes := []string{"POINTS", "COINS", "TOKENS", "REWARD"}
return contains(validTypes, rewardType)
}
@@ -293,11 +293,11 @@ func (v *CampaignValidatorImpl) validateCampaignRule(rule *contract.CampaignRule
// Validate reward type
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
if rule.RewardType == "POINTS" || rule.RewardType == "TOKENS" {
if rule.RewardType == "POINTS" || rule.RewardType == "COINS" || rule.RewardType == "TOKENS" {
if rule.RewardValue == nil || *rule.RewardValue <= 0 {
return errors.New("reward value must be positive for " + rule.RewardType + " type in rule " + string(rune(ruleNumber)))
}