package processor import ( "bytes" "context" "encoding/csv" "encoding/json" "errors" "fmt" "io" "strings" "time" "github.com/google/uuid" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) const ( // voucherCodeImportLimit caps the lines of one import. voucherCodeImportLimit = 50_000 // voucherDuplicateReportLimit caps the duplicate codes an import lists back. voucherDuplicateReportLimit = 100 ) // VoucherAdminProcessor manages an organization's EnakGame vouchers and their code // pools (docs/rfc-enakgame.md §5.7, §11). Every change is audited in its transaction. type VoucherAdminProcessor struct { vouchers repository.VoucherRepository audit *AuditLogger tx TxRunner } func NewVoucherAdminProcessor(vouchers repository.VoucherRepository, audit *AuditLogger, tx TxRunner) *VoucherAdminProcessor { return &VoucherAdminProcessor{vouchers: vouchers, audit: audit, tx: tx} } func (p *VoucherAdminProcessor) record(ctx context.Context, organizationID, actor, id uuid.UUID, action string, before, after any, reason *string) error { return p.audit.Record(ctx, AuditEntry{ OrganizationID: organizationID, ActorType: constants.AuditActorUser, ActorID: &actor, EntityType: constants.AuditEntityVoucher, EntityID: id, Action: action, Before: before, After: after, Reason: reason, Source: constants.AuditSourceAdminAPI, }) } // VoucherInputFrom is a voucher's current values as an input, for a change that only // sends the fields it changes. func VoucherInputFrom(v *models.Voucher) models.VoucherInput { return models.VoucherInput{ Name: v.Name, Description: v.Description, ImageURL: v.ImageURL, VoucherType: v.VoucherType, FaceValue: v.FaceValue, PointCost: v.PointCost, BusinessCost: v.BusinessCost, StockMode: v.StockMode, Stock: v.Stock, Provider: v.Provider, ProviderRef: v.ProviderRef, MaxPerCustomer: v.MaxPerCustomer, ValidFrom: v.ValidFrom, ValidUntil: v.ValidUntil, Terms: v.Terms, Status: v.Status, } } func (p *VoucherAdminProcessor) CreateVoucher(ctx context.Context, organizationID, actor uuid.UUID, in models.VoucherInput) (*models.Voucher, error) { if in.Status == "" { in.Status = constants.VoucherStatusDraft } in.Status = strings.ToUpper(strings.TrimSpace(in.Status)) switch in.Status { case constants.VoucherStatusDraft, constants.VoucherStatusActive, constants.VoucherStatusInactive: default: return nil, enakGameRejected("a new voucher must be DRAFT, ACTIVE or INACTIVE") } voucher := &entities.Voucher{OrganizationID: organizationID, Status: in.Status} if err := applyVoucherInput(voucher, in); err != nil { return nil, err } err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { if err := p.vouchers.CreateVoucher(ctx, voucher); err != nil { return err } return p.record(ctx, organizationID, actor, voucher.ID, "CREATED", nil, voucherModel(voucher), nil) }) if err != nil { return nil, err } return voucherModel(voucher), nil } func (p *VoucherAdminProcessor) GetVoucher(ctx context.Context, organizationID, id uuid.UUID) (*models.Voucher, error) { voucher, err := p.vouchers.GetVoucher(ctx, organizationID, id) if err != nil { return nil, err } return voucherModel(voucher), nil } // ListVouchers returns a page of vouchers. ARCHIVED ones are left out unless asked // for by status. func (p *VoucherAdminProcessor) ListVouchers(ctx context.Context, organizationID uuid.UUID, q models.VoucherListQuery) (*models.PaginatedResponse[models.Voucher], error) { page, limit := enakGamePage(q.Page, q.Limit) statuses := []string{constants.VoucherStatusDraft, constants.VoucherStatusActive, constants.VoucherStatusInactive} if q.Status != "" { status := strings.ToUpper(strings.TrimSpace(q.Status)) if !isVoucherStatus(status) { return nil, enakGameRejected("unknown status %q", q.Status) } statuses = []string{status} } vouchers, total, err := p.vouchers.ListVouchers(ctx, repository.VoucherFilter{ OrganizationID: organizationID, Statuses: statuses, Search: strings.TrimSpace(q.Search), Offset: (page - 1) * limit, Limit: limit, }) if err != nil { return nil, err } items := make([]models.Voucher, 0, len(vouchers)) for i := range vouchers { items = append(items, *voucherModel(&vouchers[i])) } return &models.PaginatedResponse[models.Voucher]{Data: items, Pagination: enakGamePagination(page, limit, total)}, nil } // UpdateVoucher changes everything but the stock mode and the status. Redemptions // already made keep the numbers they froze. func (p *VoucherAdminProcessor) UpdateVoucher(ctx context.Context, organizationID, actor, id uuid.UUID, in models.VoucherInput) (*models.Voucher, error) { var after *models.Voucher err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { voucher, err := p.vouchers.LockVoucher(ctx, organizationID, id) if err != nil { return err } if voucher.Status == constants.VoucherStatusArchived { return enakGameRejected("an archived voucher cannot change") } if !strings.EqualFold(strings.TrimSpace(in.StockMode), voucher.StockMode) { return enakGameRejected("the stock mode of a voucher cannot change") } before := voucherModel(voucher) if err := applyVoucherInput(voucher, in); err != nil { return err } if err := p.vouchers.UpdateVoucher(ctx, voucher); err != nil { return err } saved, err := p.vouchers.GetVoucher(ctx, organizationID, id) if err != nil { return err } after = voucherModel(saved) return p.record(ctx, organizationID, actor, id, "UPDATED", before, after, nil) }) if err != nil { return nil, err } return after, nil } // SetVoucherStatus moves a voucher between DRAFT, ACTIVE and INACTIVE, or archives it // for good. func (p *VoucherAdminProcessor) SetVoucherStatus(ctx context.Context, organizationID, actor, id uuid.UUID, in models.VoucherStatusInput) (*models.Voucher, error) { status := strings.ToUpper(strings.TrimSpace(in.Status)) if !isVoucherStatus(status) { return nil, enakGameRejected("status must be DRAFT, ACTIVE, INACTIVE or ARCHIVED") } if err := validateReason(in.Reason); err != nil { return nil, err } err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { voucher, err := p.vouchers.LockVoucher(ctx, organizationID, id) if err != nil { return err } if voucher.Status == status { return nil } if voucher.Status == constants.VoucherStatusArchived { return enakGameRejected("an archived voucher cannot change") } if err := p.vouchers.SetVoucherStatus(ctx, organizationID, id, status); err != nil { return err } return p.record(ctx, organizationID, actor, id, "STATUS_CHANGED", map[string]string{"status": voucher.Status}, map[string]string{"status": status}, in.Reason) }) if err != nil { return nil, err } return p.GetVoucher(ctx, organizationID, id) } // ImportCodes adds codes from a CSV to a CODE_POOL voucher. Each line holds a code and, // optionally, when it expires (YYYY-MM-DD, the end of that day in Asia/Jakarta, or // RFC 3339); a first line "code" is a header. A code already in the pool or repeated in // the file is skipped and listed back, so importing the same file twice adds nothing // the second time. A line that cannot be read is skipped and reported. func (p *VoucherAdminProcessor) ImportCodes(ctx context.Context, organizationID, actor, voucherID uuid.UUID, data []byte) (*models.VoucherCodeImportResult, error) { codes, invalid, repeated, err := parseVoucherCodes(data) if err != nil { return nil, err } result := &models.VoucherCodeImportResult{Duplicates: []string{}, Invalid: invalid} err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { voucher, err := p.vouchers.LockVoucher(ctx, organizationID, voucherID) if err != nil { return err } if voucher.StockMode != constants.VoucherStockCodePool { return enakGameRejected("only a CODE_POOL voucher holds codes") } if voucher.Status == constants.VoucherStatusArchived { return enakGameRejected("an archived voucher cannot change") } added, err := p.vouchers.ImportCodes(ctx, voucherID, codes) if err != nil { return err } addedSet := make(map[string]bool, len(added)) for _, code := range added { addedSet[code] = true } duplicates := repeated for _, c := range codes { if !addedSet[c.Code] { duplicates = append(duplicates, c.Code) } } result.Imported = len(added) result.DuplicateCount = len(duplicates) if len(duplicates) > voucherDuplicateReportLimit { duplicates = duplicates[:voucherDuplicateReportLimit] } result.Duplicates = append(result.Duplicates, duplicates...) return p.record(ctx, organizationID, actor, voucherID, "CODES_IMPORTED", nil, map[string]int{ "imported": result.Imported, "duplicates": result.DuplicateCount, "invalid": len(result.Invalid), }, nil) }) if err != nil { return nil, err } return result, nil } // Codes returns how many of a pool's codes are in each status and a page of them. func (p *VoucherAdminProcessor) Codes(ctx context.Context, organizationID, voucherID uuid.UUID, q models.VoucherCodeListQuery) (*models.VoucherCodes, error) { if _, err := p.vouchers.GetVoucher(ctx, organizationID, voucherID); err != nil { return nil, err } status := strings.ToUpper(strings.TrimSpace(q.Status)) switch status { case "", constants.VoucherCodeAvailable, constants.VoucherCodeReserved, constants.VoucherCodeRedeemed, constants.VoucherCodeExpired, constants.VoucherCodeCancelled: default: return nil, enakGameRejected("unknown code status %q", q.Status) } counts, err := p.vouchers.CountCodes(ctx, voucherID) if err != nil { return nil, err } page, limit := enakGamePage(q.Page, q.Limit) codes, total, err := p.vouchers.ListCodes(ctx, voucherID, status, (page-1)*limit, limit) if err != nil { return nil, err } items := make([]models.VoucherCode, 0, len(codes)) for _, c := range codes { items = append(items, models.VoucherCode{ID: c.ID, Code: c.Code, Status: c.Status, RedemptionID: c.RedemptionID, ExpiresAt: c.ExpiresAt, CreatedAt: c.CreatedAt}) } return &models.VoucherCodes{ Counts: counts, Codes: models.PaginatedResponse[models.VoucherCode]{Data: items, Pagination: enakGamePagination(page, limit, total)}, }, nil } // ExpireCodes moves every available code past its expiry to EXPIRED, a batch at a // time, and returns how many (§12). func (p *VoucherAdminProcessor) ExpireCodes(ctx context.Context, now time.Time) (int64, error) { const batch = 500 var expired int64 for { n, err := p.vouchers.ExpireCodes(ctx, now, batch) expired += n if err != nil || n < batch { return expired, err } } } // parseVoucherCodes reads codes from a CSV. It returns the codes, the lines it could // not read, and the codes repeated within the file. func parseVoucherCodes(data []byte) ([]repository.VoucherCodeImport, []models.VoucherCodeImportProblem, []string, error) { reader := csv.NewReader(bytes.NewReader(data)) reader.FieldsPerRecord = -1 reader.TrimLeadingSpace = true var codes []repository.VoucherCodeImport invalid := []models.VoucherCodeImportProblem{} var repeated []string seen := map[string]bool{} for line := 1; ; line++ { record, err := reader.Read() if errors.Is(err, io.EOF) { break } if err != nil { invalid = append(invalid, models.VoucherCodeImportProblem{Line: line, Reason: err.Error()}) continue } if line > voucherCodeImportLimit { return nil, nil, nil, enakGameRejected("an import takes at most %d lines", voucherCodeImportLimit) } code := strings.TrimSpace(record[0]) if line == 1 && strings.EqualFold(code, "code") { continue } if code == "" { continue } if len(code) > 255 { invalid = append(invalid, models.VoucherCodeImportProblem{Line: line, Reason: "the code is longer than 255 characters"}) continue } var expiresAt *time.Time if len(record) > 1 && strings.TrimSpace(record[1]) != "" { t, err := parseCodeExpiry(strings.TrimSpace(record[1])) if err != nil { invalid = append(invalid, models.VoucherCodeImportProblem{Line: line, Reason: err.Error()}) continue } expiresAt = &t } if seen[code] { repeated = append(repeated, code) continue } seen[code] = true codes = append(codes, repository.VoucherCodeImport{Code: code, ExpiresAt: expiresAt}) } if len(codes) == 0 && len(invalid) == 0 { return nil, nil, nil, enakGameRejected("the file holds no codes") } return codes, invalid, repeated, nil } func parseCodeExpiry(raw string) (time.Time, error) { if day, err := time.ParseInLocation("2006-01-02", raw, walletDisplayLocation); err == nil { return *endOfWalletDay(day), nil } if t, err := time.Parse(time.RFC3339, raw); err == nil { return t, nil } return time.Time{}, fmt.Errorf("expiry %q is not a date like 2026-12-31", raw) } func isVoucherStatus(s string) bool { switch s { case constants.VoucherStatusDraft, constants.VoucherStatusActive, constants.VoucherStatusInactive, constants.VoucherStatusArchived: return true } return false } // applyVoucherInput checks an input and copies it onto the voucher, leaving its // organization, stock mode (once set) and status alone. func applyVoucherInput(v *entities.Voucher, in models.VoucherInput) error { name := strings.TrimSpace(in.Name) voucherType := strings.ToUpper(strings.TrimSpace(in.VoucherType)) stockMode := strings.ToUpper(strings.TrimSpace(in.StockMode)) switch { case name == "" || len(name) > 255: return enakGameRejected("name is required, at most 255 characters") case voucherType != constants.VoucherTypeFixedValue && voucherType != constants.VoucherTypePercentage && voucherType != constants.VoucherTypeFreeItem && voucherType != constants.VoucherTypeMerchantBenefit: return enakGameRejected("voucher_type must be FIXED_VALUE, PERCENTAGE, FREE_ITEM or MERCHANT_BENEFIT") case in.FaceValue <= 0: return enakGameRejected("face_value must be more than 0") case in.PointCost <= 0: return enakGameRejected("point_cost must be more than 0") case in.BusinessCost != nil && *in.BusinessCost < 0: return enakGameRejected("business_cost must be 0 or more") case stockMode != constants.VoucherStockStatic && stockMode != constants.VoucherStockCodePool && stockMode != constants.VoucherStockExternal: return enakGameRejected("stock_mode must be STATIC, CODE_POOL or EXTERNAL") case (stockMode == constants.VoucherStockStatic) != (in.Stock != nil): return enakGameRejected("stock is required for a STATIC voucher and only for one") case in.Stock != nil && *in.Stock < 0: return enakGameRejected("stock must be 0 or more") case (stockMode == constants.VoucherStockExternal) != (in.Provider != nil && strings.TrimSpace(*in.Provider) != ""): return enakGameRejected("provider is required for an EXTERNAL voucher and only for one") case in.Provider != nil && len(*in.Provider) > 50: return enakGameRejected("provider must be at most 50 characters") case in.ProviderRef != nil && len(*in.ProviderRef) > 255: return enakGameRejected("provider_ref must be at most 255 characters") case in.MaxPerCustomer != nil && *in.MaxPerCustomer < 1: return enakGameRejected("max_per_customer must be at least 1, or left out for no limit") case in.ValidFrom != nil && in.ValidUntil != nil && !in.ValidUntil.After(*in.ValidFrom): return enakGameRejected("valid_until must be after valid_from") case in.ImageURL != nil && len(*in.ImageURL) > 500: return enakGameRejected("image_url must be at most 500 characters") } terms := entities.JSONDocument(`{}`) if len(bytes.TrimSpace(in.Terms)) > 0 && string(bytes.TrimSpace(in.Terms)) != "null" { var object map[string]any if err := json.Unmarshal(in.Terms, &object); err != nil { return enakGameRejected("terms must be a JSON object") } var compact bytes.Buffer if err := json.Compact(&compact, in.Terms); err != nil { return enakGameRejected("terms must be a JSON object") } terms = entities.JSONDocument(compact.Bytes()) } v.Name, v.Description, v.ImageURL, v.VoucherType = name, in.Description, in.ImageURL, voucherType v.FaceValue, v.PointCost, v.BusinessCost, v.StockMode = in.FaceValue, in.PointCost, in.BusinessCost, stockMode v.Stock, v.Provider, v.ProviderRef, v.MaxPerCustomer = in.Stock, in.Provider, in.ProviderRef, in.MaxPerCustomer v.ValidFrom, v.ValidUntil, v.Terms = in.ValidFrom, in.ValidUntil, terms return nil } func voucherModel(v *entities.Voucher) *models.Voucher { return &models.Voucher{ ID: v.ID, Name: v.Name, Description: v.Description, ImageURL: v.ImageURL, VoucherType: v.VoucherType, FaceValue: v.FaceValue, PointCost: v.PointCost, BusinessCost: v.BusinessCost, StockMode: v.StockMode, Stock: v.Stock, Provider: v.Provider, ProviderRef: v.ProviderRef, MaxPerCustomer: v.MaxPerCustomer, ValidFrom: v.ValidFrom, ValidUntil: v.ValidUntil, Terms: json.RawMessage(v.Terms), Status: v.Status, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, } }