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>
376 lines
11 KiB
Go
376 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"apskel-pos-be/internal/contract"
|
|
"apskel-pos-be/internal/models"
|
|
"apskel-pos-be/internal/processor"
|
|
)
|
|
|
|
type CampaignService interface {
|
|
CreateCampaign(ctx context.Context, req *contract.CreateCampaignRequest) (*models.CampaignResponse, error)
|
|
GetCampaign(ctx context.Context, id string) (*models.CampaignResponse, error)
|
|
ListCampaigns(ctx context.Context, req *contract.ListCampaignsRequest) (*models.ListCampaignsResponse, error)
|
|
UpdateCampaign(ctx context.Context, req *contract.UpdateCampaignRequest) (*models.CampaignResponse, error)
|
|
DeleteCampaign(ctx context.Context, id string) error
|
|
GetActiveCampaigns(ctx context.Context) ([]models.CampaignResponse, error)
|
|
GetCampaignsForApp(ctx context.Context) ([]models.CampaignResponse, error)
|
|
// Campaign Rules methods
|
|
CreateCampaignRule(ctx context.Context, req *contract.CreateCampaignRuleRequest) (*models.CampaignRuleResponse, error)
|
|
GetCampaignRule(ctx context.Context, id string) (*models.CampaignRuleResponse, error)
|
|
ListCampaignRules(ctx context.Context, req *contract.ListCampaignRulesRequest) (*models.ListCampaignRulesResponse, error)
|
|
UpdateCampaignRule(ctx context.Context, req *contract.UpdateCampaignRuleRequest) (*models.CampaignRuleResponse, error)
|
|
DeleteCampaignRule(ctx context.Context, id string) error
|
|
GetCampaignRulesByCampaignID(ctx context.Context, campaignID string) ([]models.CampaignRuleResponse, error)
|
|
}
|
|
|
|
type campaignService struct {
|
|
campaignProcessor processor.CampaignProcessor
|
|
campaignRuleProcessor processor.CampaignRuleProcessor
|
|
}
|
|
|
|
func NewCampaignService(campaignProcessor processor.CampaignProcessor, campaignRuleProcessor processor.CampaignRuleProcessor) CampaignService {
|
|
return &campaignService{
|
|
campaignProcessor: campaignProcessor,
|
|
campaignRuleProcessor: campaignRuleProcessor,
|
|
}
|
|
}
|
|
|
|
func (s *campaignService) CreateCampaign(ctx context.Context, req *contract.CreateCampaignRequest) (*models.CampaignResponse, error) {
|
|
// Validate campaign type
|
|
if err := s.validateCampaignType(req.Type); err != nil {
|
|
return nil, fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
// Validate date range
|
|
if err := s.validateDateRange(req.StartDate, req.EndDate); err != nil {
|
|
return nil, fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
// Validate position
|
|
if req.Position < 0 {
|
|
return nil, fmt.Errorf("position cannot be negative")
|
|
}
|
|
|
|
// Create campaign
|
|
response, err := s.campaignProcessor.CreateCampaign(ctx, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create campaign: %w", err)
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
func (s *campaignService) GetCampaign(ctx context.Context, id string) (*models.CampaignResponse, error) {
|
|
if id == "" {
|
|
return nil, fmt.Errorf("campaign ID is required")
|
|
}
|
|
|
|
response, err := s.campaignProcessor.GetCampaign(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get campaign: %w", err)
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
func (s *campaignService) ListCampaigns(ctx context.Context, req *contract.ListCampaignsRequest) (*models.ListCampaignsResponse, error) {
|
|
// Set default pagination
|
|
if req.Page <= 0 {
|
|
req.Page = 1
|
|
}
|
|
if req.Limit <= 0 {
|
|
req.Limit = 10
|
|
}
|
|
if req.Limit > 100 {
|
|
req.Limit = 100
|
|
}
|
|
|
|
// Validate campaign type filter if provided
|
|
if req.Type != "" {
|
|
if err := s.validateCampaignType(req.Type); err != nil {
|
|
return nil, fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
}
|
|
|
|
response, err := s.campaignProcessor.ListCampaigns(ctx, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list campaigns: %w", err)
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
func (s *campaignService) UpdateCampaign(ctx context.Context, req *contract.UpdateCampaignRequest) (*models.CampaignResponse, error) {
|
|
if req.ID.String() == "" {
|
|
return nil, fmt.Errorf("campaign ID is required")
|
|
}
|
|
|
|
// Validate campaign type
|
|
if err := s.validateCampaignType(req.Type); err != nil {
|
|
return nil, fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
// Validate date range
|
|
if err := s.validateDateRange(req.StartDate, req.EndDate); err != nil {
|
|
return nil, fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
// Validate position
|
|
if req.Position < 0 {
|
|
return nil, fmt.Errorf("position cannot be negative")
|
|
}
|
|
|
|
response, err := s.campaignProcessor.UpdateCampaign(ctx, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to update campaign: %w", err)
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
func (s *campaignService) DeleteCampaign(ctx context.Context, id string) error {
|
|
if id == "" {
|
|
return fmt.Errorf("campaign ID is required")
|
|
}
|
|
|
|
err := s.campaignProcessor.DeleteCampaign(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete campaign: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *campaignService) GetActiveCampaigns(ctx context.Context) ([]models.CampaignResponse, error) {
|
|
campaigns, err := s.campaignProcessor.GetActiveCampaigns(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get active campaigns: %w", err)
|
|
}
|
|
|
|
return campaigns, nil
|
|
}
|
|
|
|
func (s *campaignService) GetCampaignsForApp(ctx context.Context) ([]models.CampaignResponse, error) {
|
|
campaigns, err := s.campaignProcessor.GetCampaignsForApp(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get campaigns for app: %w", err)
|
|
}
|
|
|
|
return campaigns, nil
|
|
}
|
|
|
|
// Campaign Rules methods
|
|
|
|
func (s *campaignService) CreateCampaignRule(ctx context.Context, req *contract.CreateCampaignRuleRequest) (*models.CampaignRuleResponse, error) {
|
|
// Validate rule type
|
|
if err := s.validateRuleType(req.RuleType); err != nil {
|
|
return nil, fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
// Validate reward type
|
|
if err := s.validateRewardType(req.RewardType); err != nil {
|
|
return nil, fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
// Validate reward value based on reward type
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Validate reward reference ID for REWARD type
|
|
if req.RewardType == "REWARD" {
|
|
if req.RewardRefID == nil {
|
|
return nil, fmt.Errorf("reward reference ID is required for REWARD type")
|
|
}
|
|
}
|
|
|
|
response, err := s.campaignRuleProcessor.CreateCampaignRule(ctx, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create campaign rule: %w", err)
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
func (s *campaignService) GetCampaignRule(ctx context.Context, id string) (*models.CampaignRuleResponse, error) {
|
|
if id == "" {
|
|
return nil, fmt.Errorf("campaign rule ID is required")
|
|
}
|
|
|
|
response, err := s.campaignRuleProcessor.GetCampaignRule(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get campaign rule: %w", err)
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
func (s *campaignService) ListCampaignRules(ctx context.Context, req *contract.ListCampaignRulesRequest) (*models.ListCampaignRulesResponse, error) {
|
|
// Set default pagination
|
|
if req.Page <= 0 {
|
|
req.Page = 1
|
|
}
|
|
if req.Limit <= 0 {
|
|
req.Limit = 10
|
|
}
|
|
if req.Limit > 100 {
|
|
req.Limit = 100
|
|
}
|
|
|
|
response, err := s.campaignRuleProcessor.ListCampaignRules(ctx, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list campaign rules: %w", err)
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
func (s *campaignService) UpdateCampaignRule(ctx context.Context, req *contract.UpdateCampaignRuleRequest) (*models.CampaignRuleResponse, error) {
|
|
if req.ID.String() == "" {
|
|
return nil, fmt.Errorf("campaign rule ID is required")
|
|
}
|
|
|
|
// Validate rule type
|
|
if err := s.validateRuleType(req.RuleType); err != nil {
|
|
return nil, fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
// Validate reward type
|
|
if err := s.validateRewardType(req.RewardType); err != nil {
|
|
return nil, fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
// Validate reward value based on reward type
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Validate reward reference ID for REWARD type
|
|
if req.RewardType == "REWARD" {
|
|
if req.RewardRefID == nil {
|
|
return nil, fmt.Errorf("reward reference ID is required for REWARD type")
|
|
}
|
|
}
|
|
|
|
response, err := s.campaignRuleProcessor.UpdateCampaignRule(ctx, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to update campaign rule: %w", err)
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
func (s *campaignService) DeleteCampaignRule(ctx context.Context, id string) error {
|
|
if id == "" {
|
|
return fmt.Errorf("campaign rule ID is required")
|
|
}
|
|
|
|
err := s.campaignRuleProcessor.DeleteCampaignRule(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete campaign rule: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *campaignService) GetCampaignRulesByCampaignID(ctx context.Context, campaignID string) ([]models.CampaignRuleResponse, error) {
|
|
if campaignID == "" {
|
|
return nil, fmt.Errorf("campaign ID is required")
|
|
}
|
|
|
|
rules, err := s.campaignRuleProcessor.GetCampaignRulesByCampaignID(ctx, campaignID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get campaign rules by campaign ID: %w", err)
|
|
}
|
|
|
|
return rules, nil
|
|
}
|
|
|
|
func (s *campaignService) validateCampaignType(campaignType string) error {
|
|
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, COINS, MIXED", campaignType)
|
|
}
|
|
|
|
func (s *campaignService) validateDateRange(startDate, endDate time.Time) error {
|
|
if startDate.IsZero() {
|
|
return fmt.Errorf("start date is required")
|
|
}
|
|
if endDate.IsZero() {
|
|
return fmt.Errorf("end date is required")
|
|
}
|
|
if startDate.After(endDate) {
|
|
return fmt.Errorf("start date cannot be after end date")
|
|
}
|
|
if startDate.Before(time.Now()) {
|
|
return fmt.Errorf("start date cannot be in the past")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *campaignService) validateCampaignRules(rules []contract.CampaignRuleStruct) error {
|
|
if len(rules) == 0 {
|
|
return fmt.Errorf("at least one rule is required")
|
|
}
|
|
|
|
for i, rule := range rules {
|
|
if err := s.validateRuleType(rule.RuleType); err != nil {
|
|
return fmt.Errorf("invalid rule type in rule %d: %w", i+1, err)
|
|
}
|
|
|
|
if err := s.validateRewardType(rule.RewardType); err != nil {
|
|
return fmt.Errorf("invalid reward type in rule %d: %w", i+1, err)
|
|
}
|
|
|
|
// Validate reward value based on reward type
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Validate reward reference ID for REWARD type
|
|
if rule.RewardType == "REWARD" {
|
|
if rule.RewardRefID == nil {
|
|
return fmt.Errorf("reward reference ID is required for REWARD type in rule %d", i+1)
|
|
}
|
|
}
|
|
|
|
_ = i // Avoid unused variable warning
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *campaignService) validateRuleType(ruleType string) error {
|
|
validTypes := []string{"TIER", "SPEND", "PRODUCT", "CATEGORY", "DAY", "LOCATION"}
|
|
for _, validType := range validTypes {
|
|
if ruleType == validType {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("invalid rule type: %s. Valid types are: TIER, SPEND, PRODUCT, CATEGORY, DAY, LOCATION", ruleType)
|
|
}
|
|
|
|
func (s *campaignService) validateRewardType(rewardType string) error {
|
|
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, COINS, REWARD", rewardType)
|
|
}
|