feat(enakgame): game sessions, rewards, vouchers, budgets and events
EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md). Foundation (phase 1) - Migrations 000103-000106: games extended with organization, slug, status, entry cost and result rules, old games archived (not deleted); budgets, versioned reward configs, sessions and session rewards; the ledger types GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs. - AuditLogger writes in the caller's transaction only. - enakgame.limit.user_daily and global_daily organization settings. Games and sessions (phases 2-4) - Admin /marketing/enakgame: games, reward config versions (immutable but for status, one ACTIVE per game), budgets with non-overlapping global periods and a daily job opening the next month. - Customer /customer/enakgame: start (Idempotency-Key, entry cost and config frozen on the session), complete (result validation, reward engine, max_reward cap, daily limits via game_reward_counters, one GAME_REWARD per budget), automatic refunds for system errors and deactivated games, and a session job. - Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand), rounded down. Vouchers and budgets (phases 5-6) - Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution; Economy Guard counters. - STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action; realized cost traced through the lots to the budget that paid the reward. - Budget metrics: realized cost, forecast, exposure and status. Migrations 000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY. Events (phase 7) - Migration 000111: game events, each with its own EVENT budget. Event extras stack per PRD §16 defaults, with event and per-customer limits. External vouchers (phase 8) - VoucherProvider contract, two-step PENDING redemption and a recovery job, tested with a fake provider. No provider adapter is registered yet, so EXTERNAL vouchers stay out of the catalog. Not yet decided before release: reward rounding, event stacking, budget exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have not been run on any shared database. Also fixes a leftover PAYMENT filter in a wallet test and a data race in a test PIN fake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2c9753fae7
commit
798a36bd6c
@@ -0,0 +1,422 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user