From 9ae5be2c33cfe3cad2c7ae7f5026f65bb1ef3677 Mon Sep 17 00:00:00 2001 From: efrilm Date: Tue, 11 Aug 2026 16:39:13 +0700 Subject: [PATCH 01/47] feat(purchasae): added team with category parent and central --- internal/app/app.go | 2 +- internal/constants/purchase_team.go | 12 ++ internal/contract/purchase_order_contract.go | 51 +++++--- internal/entities/purchase_order.go | 9 +- internal/handler/purchase_order_handler.go | 25 ++++ internal/mappers/purchase_order_mapper.go | 30 +++++ internal/models/purchase_order.go | 37 ++++-- internal/processor/category_processor.go | 1 + .../processor/purchase_order_processor.go | 96 ++++++++++++++ internal/repository/category_repository.go | 20 +++ .../repository/purchase_order_repository.go | 27 +++- internal/router/router.go | 1 + internal/service/purchase_order_service.go | 22 ++++ .../transformer/purchase_order_transformer.go | 44 +++++-- .../validator/purchase_order_validator.go | 57 +++++++++ .../purchase_order_validator_test.go | 119 ++++++++++++++++++ ...00085_add_team_to_purchase_orders.down.sql | 12 ++ .../000085_add_team_to_purchase_orders.up.sql | 33 +++++ 18 files changed, 566 insertions(+), 32 deletions(-) create mode 100644 internal/constants/purchase_team.go create mode 100644 migrations/000085_add_team_to_purchase_orders.down.sql create mode 100644 migrations/000085_add_team_to_purchase_orders.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index 6a0f4d1..42dd639 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -372,7 +372,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo), productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo), vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo), - purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), + purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.categoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo), unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo), chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo), diff --git a/internal/constants/purchase_team.go b/internal/constants/purchase_team.go new file mode 100644 index 0000000..c3cc147 --- /dev/null +++ b/internal/constants/purchase_team.go @@ -0,0 +1,12 @@ +package constants + +// A purchase order is charged to a team. Teams come from the parent product +// categories, plus Pusat for spending that belongs to no single team. +const ( + PurchaseTeamScopeCategory = "category" + PurchaseTeamScopeCentral = "central" + + // PurchaseTeamCentralName is what Pusat is called in the picker. Pusat has no + // row of its own, so the name lives here rather than in the database. + PurchaseTeamCentralName = "Pusat" +) diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 7038b4b..79b14b2 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -14,6 +14,8 @@ type CreatePurchaseOrderRequest struct { Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"` Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` Message *string `json:"message,omitempty" validate:"omitempty"` + TeamScope *string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"` Items []CreatePurchaseOrderItemRequest `json:"items" validate:"required,min=1,dive"` AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"` } @@ -28,13 +30,16 @@ type CreatePurchaseOrderItemRequest struct { } type UpdatePurchaseOrderRequest struct { - VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"` - PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"` - TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD - DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD - Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"` - Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` - Message *string `json:"message,omitempty" validate:"omitempty"` + VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"` + PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"` + TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD + DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD + Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"` + Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` + Message *string `json:"message,omitempty" validate:"omitempty"` + // An empty string clears the team; omitting the field leaves it untouched. + TeamScope *string `json:"team_scope,omitempty" validate:"omitempty"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"` Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty" validate:"omitempty,dive"` AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"` } @@ -61,13 +66,29 @@ type PurchaseOrderResponse struct { Status string `json:"status"` Message *string `json:"message"` TotalAmount float64 `json:"total_amount"` + TeamScope *string `json:"team_scope"` + TeamCategoryID *uuid.UUID `json:"team_category_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + Team *PurchaseTeamResponse `json:"team,omitempty"` Vendor *VendorResponse `json:"vendor,omitempty"` Items []PurchaseOrderItemResponse `json:"items,omitempty"` Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"` } +// PurchaseTeamResponse is one entry of the team picker. Teams come from the parent +// product categories; Pusat is the extra entry that has no category behind it, so +// its CategoryID is null. +type PurchaseTeamResponse struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` +} + +type ListPurchaseTeamsResponse struct { + Teams []PurchaseTeamResponse `json:"teams"` +} + type PurchaseOrderItemResponse struct { ID uuid.UUID `json:"id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"` @@ -93,13 +114,15 @@ type PurchaseOrderAttachmentResponse struct { } type ListPurchaseOrdersRequest struct { - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` - Search string `json:"search,omitempty"` - Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` - VendorID *uuid.UUID `json:"vendor_id,omitempty"` - StartDate *time.Time `json:"start_date,omitempty"` - EndDate *time.Time `json:"end_date,omitempty"` + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Search string `json:"search,omitempty"` + Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` + VendorID *uuid.UUID `json:"vendor_id,omitempty"` + TeamScope string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + StartDate *time.Time `json:"start_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` } type ListPurchaseOrdersResponse struct { diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index ba5209e..e6f5208 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -20,12 +20,17 @@ type PurchaseOrder struct { Status string `gorm:"not null;size:20;default:'draft'" json:"status" validate:"required,oneof=draft sent approved received cancelled"` Message *string `gorm:"type:text" json:"message" validate:"omitempty"` TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_amount"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + // TeamScope is 'category' when the purchase is charged to a parent category, or + // 'central' for Pusat. Nil means no team was chosen, which is not the same as Pusat. + TeamScope *string `gorm:"size:20;index" json:"team_scope" validate:"omitempty,oneof=category central"` + TeamCategoryID *uuid.UUID `gorm:"type:uuid;index" json:"team_category_id" validate:"omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"` + TeamCategory *Category `gorm:"foreignKey:TeamCategoryID" json:"team_category,omitempty"` Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"` Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"` } diff --git a/internal/handler/purchase_order_handler.go b/internal/handler/purchase_order_handler.go index 6be1cff..b210fd0 100644 --- a/internal/handler/purchase_order_handler.go +++ b/internal/handler/purchase_order_handler.go @@ -176,6 +176,16 @@ func (h *PurchaseOrderHandler) ListPurchaseOrders(c *gin.Context) { } } + if teamScope := c.Query("team_scope"); teamScope != "" { + req.TeamScope = teamScope + } + + if teamCategoryIDStr := c.Query("team_category_id"); teamCategoryIDStr != "" { + if teamCategoryID, err := uuid.Parse(teamCategoryIDStr); err == nil { + req.TeamCategoryID = &teamCategoryID + } + } + if startDateStr := c.Query("start_date"); startDateStr != "" { if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil { req.StartDate = &startDate @@ -224,6 +234,21 @@ func (h *PurchaseOrderHandler) GetPurchaseOrdersByStatus(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, poResponse, "PurchaseOrderHandler::GetPurchaseOrdersByStatus") } +// ListPurchaseTeams serves the team picker for the purchase form: the parent +// categories of the caller's outlet, plus Pusat. +func (h *PurchaseOrderHandler) ListPurchaseTeams(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + teamsResponse := h.purchaseOrderService.ListPurchaseTeams(ctx, contextInfo) + if teamsResponse.HasErrors() { + errorResp := teamsResponse.GetErrors()[0] + logger.FromContext(ctx).WithError(errorResp).Error("PurchaseOrderHandler::ListPurchaseTeams -> Failed to list purchase teams from service") + } + + util.HandleResponse(c.Writer, c.Request, teamsResponse, "PurchaseOrderHandler::ListPurchaseTeams") +} + func (h *PurchaseOrderHandler) GetOverduePurchaseOrders(c *gin.Context) { ctx := c.Request.Context() contextInfo := appcontext.FromGinContext(ctx) diff --git a/internal/mappers/purchase_order_mapper.go b/internal/mappers/purchase_order_mapper.go index 5be72d2..dddaa11 100644 --- a/internal/mappers/purchase_order_mapper.go +++ b/internal/mappers/purchase_order_mapper.go @@ -1,10 +1,33 @@ package mappers import ( + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" ) +// purchaseTeamFromEntity renders the team a purchase order is charged to. It returns +// nil when no team was chosen, which is distinct from a purchase charged to Pusat. +// The category name is only filled in when TeamCategory was preloaded. +func purchaseTeamFromEntity(entity *entities.PurchaseOrder) *models.PurchaseTeam { + if entity.TeamScope == nil { + return nil + } + + team := &models.PurchaseTeam{Scope: *entity.TeamScope} + switch *entity.TeamScope { + case constants.PurchaseTeamScopeCentral: + team.Name = constants.PurchaseTeamCentralName + case constants.PurchaseTeamScopeCategory: + team.CategoryID = entity.TeamCategoryID + if entity.TeamCategory != nil { + team.Name = entity.TeamCategory.Name + } + } + + return team +} + func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.PurchaseOrder { if entity == nil { return nil @@ -22,6 +45,8 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase Status: entity.Status, Message: entity.Message, TotalAmount: entity.TotalAmount, + TeamScope: entity.TeamScope, + TeamCategoryID: entity.TeamCategoryID, CreatedAt: entity.CreatedAt, UpdatedAt: entity.UpdatedAt, } @@ -44,6 +69,8 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO Status: model.Status, Message: model.Message, TotalAmount: model.TotalAmount, + TeamScope: model.TeamScope, + TeamCategoryID: model.TeamCategoryID, CreatedAt: model.CreatedAt, UpdatedAt: model.UpdatedAt, } @@ -66,8 +93,11 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch Status: entity.Status, Message: entity.Message, TotalAmount: entity.TotalAmount, + TeamScope: entity.TeamScope, + TeamCategoryID: entity.TeamCategoryID, CreatedAt: entity.CreatedAt, UpdatedAt: entity.UpdatedAt, + Team: purchaseTeamFromEntity(entity), } // Map vendor if present diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index 7122ed8..e5e1287 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -18,10 +18,20 @@ type PurchaseOrder struct { Status string `json:"status"` Message *string `json:"message"` TotalAmount float64 `json:"total_amount"` + TeamScope *string `json:"team_scope"` + TeamCategoryID *uuid.UUID `json:"team_category_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +// PurchaseTeam is one entry of the team picker: either a parent category or Pusat. +// Pusat carries no CategoryID because it has no category of its own. +type PurchaseTeam struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` +} + type PurchaseOrderItem struct { ID uuid.UUID `json:"id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"` @@ -54,8 +64,11 @@ type PurchaseOrderResponse struct { Status string `json:"status"` Message *string `json:"message"` TotalAmount float64 `json:"total_amount"` + TeamScope *string `json:"team_scope"` + TeamCategoryID *uuid.UUID `json:"team_category_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + Team *PurchaseTeam `json:"team,omitempty"` Vendor *VendorResponse `json:"vendor,omitempty"` Items []PurchaseOrderItemResponse `json:"items,omitempty"` Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"` @@ -94,6 +107,8 @@ type CreatePurchaseOrderRequest struct { Reference *string `json:"reference,omitempty"` Status *string `json:"status,omitempty"` Message *string `json:"message,omitempty"` + TeamScope *string `json:"team_scope,omitempty"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` Items []CreatePurchaseOrderItemRequest `json:"items"` AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"` } @@ -115,6 +130,8 @@ type UpdatePurchaseOrderRequest struct { Reference *string `json:"reference,omitempty"` Status *string `json:"status,omitempty"` Message *string `json:"message,omitempty"` + TeamScope *string `json:"team_scope,omitempty"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty"` AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"` } @@ -130,13 +147,19 @@ type UpdatePurchaseOrderItemRequest struct { } type ListPurchaseOrdersRequest struct { - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` - Search string `json:"search,omitempty"` - Status string `json:"status,omitempty"` - VendorID *uuid.UUID `json:"vendor_id,omitempty"` - StartDate *time.Time `json:"start_date,omitempty"` - EndDate *time.Time `json:"end_date,omitempty"` + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Search string `json:"search,omitempty"` + Status string `json:"status,omitempty"` + VendorID *uuid.UUID `json:"vendor_id,omitempty"` + TeamScope string `json:"team_scope,omitempty"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + StartDate *time.Time `json:"start_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` +} + +type ListPurchaseTeamsResponse struct { + Teams []PurchaseTeam `json:"teams"` } type ListPurchaseOrdersResponse struct { diff --git a/internal/processor/category_processor.go b/internal/processor/category_processor.go index 73be6d5..26eba2c 100644 --- a/internal/processor/category_processor.go +++ b/internal/processor/category_processor.go @@ -24,6 +24,7 @@ type CategoryRepository interface { GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) GetWithProducts(ctx context.Context, id uuid.UUID) (*entities.Category, error) GetByOrganization(ctx context.Context, organizationID uuid.UUID) ([]*entities.Category, error) + ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error) GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error) Update(ctx context.Context, category *entities.Category) error Delete(ctx context.Context, id uuid.UUID) error diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index fe8de6e..3d0e150 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -1,11 +1,13 @@ package processor import ( + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" "context" "fmt" + "strings" "github.com/google/uuid" ) @@ -19,6 +21,7 @@ type PurchaseOrderProcessor interface { GetPurchaseOrdersByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*models.PurchaseOrderResponse, error) GetOverduePurchaseOrders(ctx context.Context, organizationID uuid.UUID) ([]*models.PurchaseOrderResponse, error) UpdatePurchaseOrderStatus(ctx context.Context, id, organizationID, userID, outletID uuid.UUID, status string) (*models.PurchaseOrderResponse, error) + ListPurchaseTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) } type PurchaseOrderProcessorImpl struct { @@ -26,6 +29,7 @@ type PurchaseOrderProcessorImpl struct { vendorRepo VendorRepository ingredientRepo IngredientRepository purchaseCategoryRepo PurchaseCategoryRepository + categoryRepo CategoryRepository unitRepo UnitRepository fileRepo FileRepository inventoryMovementService InventoryMovementService @@ -37,6 +41,7 @@ func NewPurchaseOrderProcessorImpl( vendorRepo VendorRepository, ingredientRepo IngredientRepository, purchaseCategoryRepo PurchaseCategoryRepository, + categoryRepo CategoryRepository, unitRepo UnitRepository, fileRepo FileRepository, inventoryMovementService InventoryMovementService, @@ -47,6 +52,7 @@ func NewPurchaseOrderProcessorImpl( vendorRepo: vendorRepo, ingredientRepo: ingredientRepo, purchaseCategoryRepo: purchaseCategoryRepo, + categoryRepo: categoryRepo, unitRepo: unitRepo, fileRepo: fileRepo, inventoryMovementService: inventoryMovementService, @@ -63,6 +69,11 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or } } + teamScope, teamCategoryID, err := p.resolvePurchaseTeam(ctx, organizationID, outletID, req.TeamScope, req.TeamCategoryID) + if err != nil { + return nil, err + } + // Check if PO number already exists in organization existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID) if err == nil && existingPO != nil { @@ -124,6 +135,8 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or Status: "draft", // Default status Message: req.Message, TotalAmount: totalAmount, + TeamScope: teamScope, + TeamCategoryID: teamCategoryID, } if req.Status != nil { @@ -221,6 +234,16 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id poEntity.Message = req.Message } + // An omitted team_scope leaves the team as it is; an empty one clears it. + if req.TeamScope != nil { + teamScope, teamCategoryID, err := p.resolvePurchaseTeam(ctx, organizationID, poEntity.OutletID, req.TeamScope, req.TeamCategoryID) + if err != nil { + return nil, err + } + poEntity.TeamScope = teamScope + poEntity.TeamCategoryID = teamCategoryID + } + // Update items if provided if req.Items != nil { totalAmount := 0.0 @@ -501,6 +524,79 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte return mappers.PurchaseOrderEntityToResponse(updatedPO), nil } +// ListPurchaseTeams returns the teams a purchase can be charged to: the parent +// categories of the outlet in scope, followed by Pusat. Pusat has no category row, +// so it is appended here rather than read from the database. +func (p *PurchaseOrderProcessorImpl) ListPurchaseTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) { + categories, err := p.categoryRepo.ListParentCategories(ctx, organizationID, outletID) + if err != nil { + return nil, fmt.Errorf("failed to list parent categories: %w", err) + } + + teams := make([]models.PurchaseTeam, 0, len(categories)+1) + for _, category := range categories { + categoryID := category.ID + teams = append(teams, models.PurchaseTeam{ + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + Name: category.Name, + }) + } + + teams = append(teams, models.PurchaseTeam{ + Scope: constants.PurchaseTeamScopeCentral, + Name: constants.PurchaseTeamCentralName, + }) + + return &models.ListPurchaseTeamsResponse{Teams: teams}, nil +} + +// resolvePurchaseTeam turns a requested team into the scope/category pair stored on +// the purchase order, mirroring the database check constraint. A nil or empty scope +// leaves the purchase without a team, which is deliberately different from Pusat. +// Which outlet's Pusat a purchase belongs to comes from the purchase order's outlet, +// so 'central' needs nothing stored beyond the scope itself. +func (p *PurchaseOrderProcessorImpl) resolvePurchaseTeam(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (*string, *uuid.UUID, error) { + if scope == nil { + return nil, nil, nil + } + + switch strings.TrimSpace(*scope) { + case "": + return nil, nil, nil + + case constants.PurchaseTeamScopeCentral: + resolved := constants.PurchaseTeamScopeCentral + return &resolved, nil, nil + + case constants.PurchaseTeamScopeCategory: + if categoryID == nil { + return nil, nil, fmt.Errorf("team_category_id is required when team_scope is category") + } + + category, err := p.categoryRepo.GetByID(ctx, *categoryID) + if err != nil { + return nil, nil, fmt.Errorf("team category not found: %w", err) + } + if category.OrganizationID != organizationID { + return nil, nil, fmt.Errorf("team category does not belong to this organization") + } + if category.ParentID != nil { + return nil, nil, fmt.Errorf("team must be a parent category") + } + // Categories without an outlet are shared, so only an outlet-specific + // category has to match the outlet the purchase is booked against. + if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID { + return nil, nil, fmt.Errorf("team category belongs to a different outlet") + } + + resolved := constants.PurchaseTeamScopeCategory + return &resolved, &category.ID, nil + } + + return nil, nil, fmt.Errorf("team_scope must be one of: category, central") +} + func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) { category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID) if err != nil { diff --git a/internal/repository/category_repository.go b/internal/repository/category_repository.go index ee4af10..1f007bf 100644 --- a/internal/repository/category_repository.go +++ b/internal/repository/category_repository.go @@ -48,6 +48,26 @@ func (r *CategoryRepositoryImpl) GetByOrganization(ctx context.Context, organiza return categories, err } +// ListParentCategories returns the top-level categories of an organization. These are +// the buckets the parent category reports roll up to via COALESCE(parent_id, id), so +// the list is deliberately every top-level category, not only those with children — +// otherwise a team could show up in a report but not be selectable on a purchase. +// Categories with no outlet of their own are shared, so they are always included. +func (r *CategoryRepositoryImpl) ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error) { + var categories []*entities.Category + + query := r.db.WithContext(ctx). + Where("organization_id = ?", organizationID). + Where("parent_id IS NULL") + + if outletID != nil { + query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID) + } + + err := query.Order("\"order\" ASC, name ASC").Find(&categories).Error + return categories, err +} + func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error) { var categories []*entities.Category err := r.db.WithContext(ctx).Where("business_type = ?", businessType).Find(&categories).Error diff --git a/internal/repository/purchase_order_repository.go b/internal/repository/purchase_order_repository.go index d65cd6b..3dd71a2 100644 --- a/internal/repository/purchase_order_repository.go +++ b/internal/repository/purchase_order_repository.go @@ -10,6 +10,7 @@ import ( "apskel-pos-be/internal/entities" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type PurchaseOrderRepositoryImpl struct { @@ -30,6 +31,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) var po entities.PurchaseOrder err := r.db.WithContext(ctx). Preload("Vendor"). + Preload("TeamCategory"). Preload("Items.Ingredient"). Preload("Items.PurchaseCategory"). Preload("Items.Unit"). @@ -45,6 +47,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte var po entities.PurchaseOrder err := r.db.WithContext(ctx). Preload("Vendor"). + Preload("TeamCategory"). Preload("Items.Ingredient"). Preload("Items.PurchaseCategory"). Preload("Items.Unit"). @@ -58,7 +61,10 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte } func (r *PurchaseOrderRepositoryImpl) Update(ctx context.Context, po *entities.PurchaseOrder) error { - return r.db.WithContext(ctx).Save(po).Error + // Omit associations so preloaded relations are not upserted back. Items and + // attachments are rewritten explicitly by the processor, and without this a + // preloaded TeamCategory would be written over the category row itself. + return r.db.WithContext(ctx).Omit(clause.Associations).Save(po).Error } func (r *PurchaseOrderRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error { @@ -87,6 +93,14 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u if vendorID, ok := value.(uuid.UUID); ok { query = query.Where("vendor_id = ?", vendorID) } + case "team_scope": + if teamScope, ok := value.(string); ok && teamScope != "" { + query = query.Where("team_scope = ?", teamScope) + } + case "team_category_id": + if teamCategoryID, ok := value.(uuid.UUID); ok { + query = query.Where("team_category_id = ?", teamCategoryID) + } case "start_date": if startDate, ok := value.(time.Time); ok { query = query.Where("transaction_date >= ?", startDate) @@ -106,6 +120,7 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u err := query. Preload("Vendor"). + Preload("TeamCategory"). Preload("Items.Ingredient"). Preload("Items.PurchaseCategory"). Preload("Items.Unit"). @@ -137,6 +152,14 @@ func (r *PurchaseOrderRepositoryImpl) Count(ctx context.Context, organizationID if vendorID, ok := value.(uuid.UUID); ok { query = query.Where("vendor_id = ?", vendorID) } + case "team_scope": + if teamScope, ok := value.(string); ok && teamScope != "" { + query = query.Where("team_scope = ?", teamScope) + } + case "team_category_id": + if teamCategoryID, ok := value.(uuid.UUID); ok { + query = query.Where("team_category_id = ?", teamCategoryID) + } case "start_date": if startDate, ok := value.(time.Time); ok { query = query.Where("transaction_date >= ?", startDate) @@ -170,6 +193,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByStatus(ctx context.Context, organizat err := r.db.WithContext(ctx). Where("organization_id = ? AND status = ?", organizationID, status). Preload("Vendor"). + Preload("TeamCategory"). Preload("Items.Ingredient"). Preload("Items.PurchaseCategory"). Preload("Items.Unit"). @@ -182,6 +206,7 @@ func (r *PurchaseOrderRepositoryImpl) GetOverdue(ctx context.Context, organizati err := r.db.WithContext(ctx). Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}). Preload("Vendor"). + Preload("TeamCategory"). Preload("Items.Ingredient"). Preload("Items.PurchaseCategory"). Preload("Items.Unit"). diff --git a/internal/router/router.go b/internal/router/router.go index 26c1dae..97d84ac 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -388,6 +388,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders) purchaseOrders.GET("/status/:status", r.purchaseOrderHandler.GetPurchaseOrdersByStatus) purchaseOrders.GET("/overdue", r.purchaseOrderHandler.GetOverduePurchaseOrders) + purchaseOrders.GET("/teams", r.purchaseOrderHandler.ListPurchaseTeams) purchaseOrders.GET("/:id", r.purchaseOrderHandler.GetPurchaseOrder) purchaseOrders.PUT("/:id", r.purchaseOrderHandler.UpdatePurchaseOrder) purchaseOrders.PUT("/:id/status/:status", r.purchaseOrderHandler.UpdatePurchaseOrderStatus) diff --git a/internal/service/purchase_order_service.go b/internal/service/purchase_order_service.go index 27fbcb7..d6d4458 100644 --- a/internal/service/purchase_order_service.go +++ b/internal/service/purchase_order_service.go @@ -21,6 +21,7 @@ type PurchaseOrderService interface { GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response GetOverduePurchaseOrders(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response UpdatePurchaseOrderStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, status string) *contract.Response + ListPurchaseTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response } type PurchaseOrderServiceImpl struct { @@ -113,6 +114,12 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx if modelReq.VendorID != nil { filters["vendor_id"] = *modelReq.VendorID } + if modelReq.TeamScope != "" { + filters["team_scope"] = modelReq.TeamScope + } + if modelReq.TeamCategoryID != nil { + filters["team_category_id"] = *modelReq.TeamCategoryID + } if modelReq.StartDate != nil { filters["start_date"] = *modelReq.StartDate } @@ -145,6 +152,21 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx return contract.BuildSuccessResponse(response) } +func (s *PurchaseOrderServiceImpl) ListPurchaseTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response { + var outletID *uuid.UUID + if apctx.OutletID != uuid.Nil { + outletID = &apctx.OutletID + } + + teams, err := s.purchaseOrderProcessor.ListPurchaseTeams(ctx, apctx.OrganizationID, outletID) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.ListPurchaseTeamsModelResponseToResponse(teams)) +} + func (s *PurchaseOrderServiceImpl) GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response { poResponses, err := s.purchaseOrderProcessor.GetPurchaseOrdersByStatus(ctx, apctx.OrganizationID, status) if err != nil { diff --git a/internal/transformer/purchase_order_transformer.go b/internal/transformer/purchase_order_transformer.go index 814b8c4..704e7e8 100644 --- a/internal/transformer/purchase_order_transformer.go +++ b/internal/transformer/purchase_order_transformer.go @@ -44,6 +44,8 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest) Reference: req.Reference, Status: req.Status, Message: req.Message, + TeamScope: req.TeamScope, + TeamCategoryID: req.TeamCategoryID, Items: items, AttachmentFileIDs: req.AttachmentFileIDs, }, nil @@ -94,6 +96,8 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest) Reference: req.Reference, Status: req.Status, Message: req.Message, + TeamScope: req.TeamScope, + TeamCategoryID: req.TeamCategoryID, Items: items, AttachmentFileIDs: req.AttachmentFileIDs, }, nil @@ -101,16 +105,39 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest) func ListPurchaseOrdersRequestToModel(req *contract.ListPurchaseOrdersRequest) *models.ListPurchaseOrdersRequest { return &models.ListPurchaseOrdersRequest{ - Page: req.Page, - Limit: req.Limit, - Search: req.Search, - Status: req.Status, - VendorID: req.VendorID, - StartDate: req.StartDate, - EndDate: req.EndDate, + Page: req.Page, + Limit: req.Limit, + Search: req.Search, + Status: req.Status, + VendorID: req.VendorID, + TeamScope: req.TeamScope, + TeamCategoryID: req.TeamCategoryID, + StartDate: req.StartDate, + EndDate: req.EndDate, } } +func PurchaseTeamModelToResponse(team *models.PurchaseTeam) *contract.PurchaseTeamResponse { + if team == nil { + return nil + } + + return &contract.PurchaseTeamResponse{ + Scope: team.Scope, + CategoryID: team.CategoryID, + Name: team.Name, + } +} + +func ListPurchaseTeamsModelResponseToResponse(resp *models.ListPurchaseTeamsResponse) *contract.ListPurchaseTeamsResponse { + teams := make([]contract.PurchaseTeamResponse, len(resp.Teams)) + for i, team := range resp.Teams { + teams[i] = *PurchaseTeamModelToResponse(&team) + } + + return &contract.ListPurchaseTeamsResponse{Teams: teams} +} + // Model to Contract conversions func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *contract.PurchaseOrderResponse { if po == nil { @@ -129,8 +156,11 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con Status: po.Status, Message: po.Message, TotalAmount: po.TotalAmount, + TeamScope: po.TeamScope, + TeamCategoryID: po.TeamCategoryID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, + Team: PurchaseTeamModelToResponse(po.Team), } // Map vendor if present diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index b578a94..3d16631 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -76,6 +76,10 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con } } + if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, false); err != nil { + return err, code + } + if len(req.Items) == 0 { return errors.New("at least one item is required"), constants.MissingFieldErrorCode } @@ -139,6 +143,10 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con } } + if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, true); err != nil { + return err, code + } + // Validate items if provided if req.Items != nil { for i, item := range req.Items { @@ -151,6 +159,40 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con return nil, "" } +// validatePurchaseTeamSelection keeps team_scope and team_category_id in step with +// the database check constraint: a category team needs a category, Pusat must not +// carry one. allowClear lets an update send an empty scope to drop the team. +func validatePurchaseTeamSelection(scope *string, categoryID *uuid.UUID, allowClear bool) (error, string) { + if scope == nil { + if categoryID != nil { + return errors.New("team_scope is required when team_category_id is provided"), constants.MissingFieldErrorCode + } + return nil, "" + } + + switch strings.TrimSpace(*scope) { + case "": + if !allowClear { + return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode + } + if categoryID != nil { + return errors.New("team_category_id must be empty when clearing the team"), constants.MalformedFieldErrorCode + } + case constants.PurchaseTeamScopeCategory: + if categoryID == nil || *categoryID == uuid.Nil { + return errors.New("team_category_id is required when team_scope is category"), constants.MissingFieldErrorCode + } + case constants.PurchaseTeamScopeCentral: + if categoryID != nil { + return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode + } + default: + return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *contract.ListPurchaseOrdersRequest) (error, string) { if req == nil { return errors.New("request body is required"), constants.MissingFieldErrorCode @@ -171,6 +213,21 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont } } + if req.TeamScope != "" { + validScopes := []string{constants.PurchaseTeamScopeCategory, constants.PurchaseTeamScopeCentral} + if !contains(validScopes, req.TeamScope) { + return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode + } + + if req.TeamScope == constants.PurchaseTeamScopeCentral && req.TeamCategoryID != nil { + return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode + } + } + + if req.TeamCategoryID != nil && *req.TeamCategoryID == uuid.Nil { + return errors.New("team_category_id cannot be empty"), constants.MalformedFieldErrorCode + } + if req.StartDate != nil && req.EndDate != nil { if req.EndDate.Before(*req.StartDate) { return errors.New("end_date must be after start_date"), constants.MalformedFieldErrorCode diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go index d7e146d..32a8eac 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -90,3 +90,122 @@ func TestPurchaseOrderValidatorCreateRejectsDueDateBeforeTransactionDate(t *test require.Equal(t, constants.MalformedFieldErrorCode, code) require.Contains(t, err.Error(), "due_date must be after transaction_date") } + +func TestPurchaseOrderValidatorCreateAllowsCentralTeam(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + scope := constants.PurchaseTeamScopeCentral + req.TeamScope = &scope + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.NoError(t, err) + require.Empty(t, code) +} + +func TestPurchaseOrderValidatorCreateRejectsCentralTeamWithCategory(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + scope := constants.PurchaseTeamScopeCentral + categoryID := uuid.New() + req.TeamScope = &scope + req.TeamCategoryID = &categoryID + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "team_category_id must be empty") +} + +func TestPurchaseOrderValidatorCreateRejectsCategoryTeamWithoutCategory(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + scope := constants.PurchaseTeamScopeCategory + req.TeamScope = &scope + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MissingFieldErrorCode, code) + require.Contains(t, err.Error(), "team_category_id is required") +} + +func TestPurchaseOrderValidatorCreateRejectsCategoryWithoutScope(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + categoryID := uuid.New() + req.TeamCategoryID = &categoryID + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MissingFieldErrorCode, code) + require.Contains(t, err.Error(), "team_scope is required") +} + +func TestPurchaseOrderValidatorCreateRejectsUnknownTeamScope(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + scope := "outlet" + req.TeamScope = &scope + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "team_scope must be one of") +} + +// An update may clear the team with an empty scope; a create may not, because +// leaving the field out already means "no team". +func TestPurchaseOrderValidatorUpdateAllowsClearingTeam(t *testing.T) { + validator := NewPurchaseOrderValidator() + scope := "" + + err, code := validator.ValidateUpdatePurchaseOrderRequest(&contract.UpdatePurchaseOrderRequest{TeamScope: &scope}) + + require.NoError(t, err) + require.Empty(t, code) +} + +func TestPurchaseOrderValidatorCreateRejectsEmptyTeamScope(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + scope := "" + req.TeamScope = &scope + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) +} + +func TestPurchaseOrderValidatorUpdateRejectsClearingTeamWithCategory(t *testing.T) { + validator := NewPurchaseOrderValidator() + scope := "" + categoryID := uuid.New() + + err, code := validator.ValidateUpdatePurchaseOrderRequest(&contract.UpdatePurchaseOrderRequest{ + TeamScope: &scope, + TeamCategoryID: &categoryID, + }) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) +} + +func TestPurchaseOrderValidatorListRejectsCentralScopeWithCategory(t *testing.T) { + validator := NewPurchaseOrderValidator() + categoryID := uuid.New() + + err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{ + Page: 1, + Limit: 10, + TeamScope: constants.PurchaseTeamScopeCentral, + TeamCategoryID: &categoryID, + }) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) +} diff --git a/migrations/000085_add_team_to_purchase_orders.down.sql b/migrations/000085_add_team_to_purchase_orders.down.sql new file mode 100644 index 0000000..0632ce0 --- /dev/null +++ b/migrations/000085_add_team_to_purchase_orders.down.sql @@ -0,0 +1,12 @@ +DROP INDEX IF EXISTS idx_purchase_orders_team_scope; +DROP INDEX IF EXISTS idx_purchase_orders_team_category_id; + +ALTER TABLE purchase_orders + DROP CONSTRAINT IF EXISTS chk_purchase_orders_team; + +ALTER TABLE purchase_orders + DROP CONSTRAINT IF EXISTS fk_purchase_orders_team_category; + +ALTER TABLE purchase_orders + DROP COLUMN IF EXISTS team_category_id, + DROP COLUMN IF EXISTS team_scope; diff --git a/migrations/000085_add_team_to_purchase_orders.up.sql b/migrations/000085_add_team_to_purchase_orders.up.sql new file mode 100644 index 0000000..86f9f7f --- /dev/null +++ b/migrations/000085_add_team_to_purchase_orders.up.sql @@ -0,0 +1,33 @@ +-- A purchase is charged either to a team (a parent product category) or to Pusat. +-- Pusat has no category of its own, so it is stored as a scope rather than a row; +-- which outlet's Pusat it is comes from purchase_orders.outlet_id. +-- team_scope IS NULL means the team was never chosen, which is deliberately +-- distinct from a purchase that belongs to Pusat. +ALTER TABLE purchase_orders + ADD COLUMN IF NOT EXISTS team_scope VARCHAR(20), + ADD COLUMN IF NOT EXISTS team_category_id UUID; + +ALTER TABLE purchase_orders + ADD CONSTRAINT fk_purchase_orders_team_category + FOREIGN KEY (team_category_id) REFERENCES categories(id) ON DELETE RESTRICT; + +-- Deleting a category that is still charged on a purchase order must fail rather +-- than silently drop the attribution, hence RESTRICT above and this pairing check. +-- Written as a CASE because an OR chain would evaluate to NULL when team_scope is +-- NULL, and a CHECK only rejects FALSE — a stray team_category_id would slip past. +ALTER TABLE purchase_orders + ADD CONSTRAINT chk_purchase_orders_team + CHECK ( + CASE + WHEN team_scope IS NULL THEN team_category_id IS NULL + WHEN team_scope = 'category' THEN team_category_id IS NOT NULL + WHEN team_scope = 'central' THEN team_category_id IS NULL + ELSE false + END + ); + +CREATE INDEX IF NOT EXISTS idx_purchase_orders_team_category_id + ON purchase_orders(team_category_id); + +CREATE INDEX IF NOT EXISTS idx_purchase_orders_team_scope + ON purchase_orders(team_scope); -- 2.54.0 From 0726fcecf0e6284efb4ea00e2ac9226c6db21d3d Mon Sep 17 00:00:00 2001 From: efrilm Date: Tue, 11 Aug 2026 21:20:17 +0700 Subject: [PATCH 02/47] feat(ingredients): make units nullable --- .../ingredient_unit_converter_contract.go | 2 +- internal/contract/product_recipe_contract.go | 2 +- internal/entities/ingredient.go | 2 +- internal/models/ingredient.go | 8 ++-- internal/models/ingredient_unit_converter.go | 2 +- internal/processor/ingredient_processor.go | 11 ++++-- .../ingredient_unit_converter_processor.go | 38 ++++++++++--------- .../order_ingredient_transaction_processor.go | 4 +- .../processor/purchase_order_processor.go | 10 +++-- ...make_ingredients_unit_id_nullable.down.sql | 6 +++ ...6_make_ingredients_unit_id_nullable.up.sql | 5 +++ 11 files changed, 54 insertions(+), 36 deletions(-) create mode 100644 migrations/000086_make_ingredients_unit_id_nullable.down.sql create mode 100644 migrations/000086_make_ingredients_unit_id_nullable.up.sql diff --git a/internal/contract/ingredient_unit_converter_contract.go b/internal/contract/ingredient_unit_converter_contract.go index 9741814..9c42d6e 100644 --- a/internal/contract/ingredient_unit_converter_contract.go +++ b/internal/contract/ingredient_unit_converter_contract.go @@ -77,7 +77,7 @@ type ListIngredientUnitConvertersResponse struct { type IngredientUnitsResponse struct { IngredientID uuid.UUID `json:"ingredient_id"` IngredientName string `json:"ingredient_name"` - BaseUnitID uuid.UUID `json:"base_unit_id"` + BaseUnitID *uuid.UUID `json:"base_unit_id"` BaseUnitName string `json:"base_unit_name"` Units []*UnitResponse `json:"units"` } diff --git a/internal/contract/product_recipe_contract.go b/internal/contract/product_recipe_contract.go index cc73de2..8806601 100644 --- a/internal/contract/product_recipe_contract.go +++ b/internal/contract/product_recipe_contract.go @@ -54,7 +54,7 @@ type ProductRecipeIngredientResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name"` - UnitID uuid.UUID `json:"unit_id"` + UnitID *uuid.UUID `json:"unit_id"` Cost float64 `json:"cost"` Stock float64 `json:"stock"` IsSemiFinished bool `json:"is_semi_finished"` diff --git a/internal/entities/ingredient.go b/internal/entities/ingredient.go index d8e7f91..4db67ca 100644 --- a/internal/entities/ingredient.go +++ b/internal/entities/ingredient.go @@ -11,7 +11,7 @@ type Ingredient struct { OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"` Name string `gorm:"not null;size:255" json:"name"` - UnitID uuid.UUID `gorm:"type:uuid;not null;index" json:"unit_id"` + UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"` Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"` Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"` IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"` diff --git a/internal/models/ingredient.go b/internal/models/ingredient.go index 7a3ac3d..e4293e6 100644 --- a/internal/models/ingredient.go +++ b/internal/models/ingredient.go @@ -12,7 +12,7 @@ type Ingredient struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name"` - UnitID uuid.UUID `json:"unit_id"` + UnitID *uuid.UUID `json:"unit_id"` Cost float64 `json:"cost"` Stock float64 `json:"stock"` IsSemiFinished bool `json:"is_semi_finished"` @@ -29,7 +29,7 @@ type CreateIngredientRequest struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name" validate:"required,min=1,max=255"` - UnitID uuid.UUID `json:"unit_id" validate:"required"` + UnitID *uuid.UUID `json:"unit_id" validate:"omitempty"` Cost float64 `json:"cost" validate:"min=0"` Stock float64 `json:"stock" validate:"min=0"` IsSemiFinished bool `json:"is_semi_finished"` @@ -48,7 +48,7 @@ type CompositionItemRequest struct { type UpdateIngredientRequest struct { OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name" validate:"required,min=1,max=255"` - UnitID uuid.UUID `json:"unit_id" validate:"required"` + UnitID *uuid.UUID `json:"unit_id" validate:"omitempty"` Cost float64 `json:"cost" validate:"min=0"` Stock float64 `json:"stock" validate:"min=0"` IsSemiFinished bool `json:"is_semi_finished"` @@ -61,7 +61,7 @@ type IngredientResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name"` - UnitID uuid.UUID `json:"unit_id"` + UnitID *uuid.UUID `json:"unit_id"` Cost float64 `json:"cost"` Stock float64 `json:"stock"` IsSemiFinished bool `json:"is_semi_finished"` diff --git a/internal/models/ingredient_unit_converter.go b/internal/models/ingredient_unit_converter.go index af14c0c..5290284 100644 --- a/internal/models/ingredient_unit_converter.go +++ b/internal/models/ingredient_unit_converter.go @@ -97,7 +97,7 @@ type ListIngredientUnitConvertersResponse struct { type IngredientUnitsResponse struct { IngredientID uuid.UUID `json:"ingredient_id"` IngredientName string `json:"ingredient_name"` - BaseUnitID uuid.UUID `json:"base_unit_id"` + BaseUnitID *uuid.UUID `json:"base_unit_id"` BaseUnitName string `json:"base_unit_name"` Units []*UnitResponse `json:"units"` } diff --git a/internal/processor/ingredient_processor.go b/internal/processor/ingredient_processor.go index 6a3ff3d..f804422 100644 --- a/internal/processor/ingredient_processor.go +++ b/internal/processor/ingredient_processor.go @@ -27,8 +27,11 @@ func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRe } func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) { - if _, err := p.unitRepo.GetByID(ctx, req.UnitID, req.OrganizationID); err != nil { - return nil, err + // The unit is optional, so it is only validated when one is supplied. + if req.UnitID != nil { + if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, req.OrganizationID); err != nil { + return nil, err + } } ingredient := &entities.Ingredient{ @@ -107,8 +110,8 @@ func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid. return nil, err } - if req.UnitID != existing.UnitID { - if _, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID); err != nil { + if req.UnitID != nil && (existing.UnitID == nil || *req.UnitID != *existing.UnitID) { + if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, organizationID); err != nil { return nil, err } } diff --git a/internal/processor/ingredient_unit_converter_processor.go b/internal/processor/ingredient_unit_converter_processor.go index 9998fe7..dd91983 100644 --- a/internal/processor/ingredient_unit_converter_processor.go +++ b/internal/processor/ingredient_unit_converter_processor.go @@ -266,15 +266,27 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex return nil, fmt.Errorf("failed to get ingredient: %w", err) } - // Get the base unit details - baseUnit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID) - if err != nil { - return nil, fmt.Errorf("failed to get base unit: %w", err) + response := &models.IngredientUnitsResponse{ + IngredientID: ingredientID, + IngredientName: ingredient.Name, } - // Start with the base unit - units := []*models.UnitResponse{ - mappers.MapUnitEntityToResponse(baseUnit), + units := make([]*models.UnitResponse, 0) + unitMap := make(map[uuid.UUID]bool) + + // An ingredient does not necessarily have a unit assigned yet. When it has + // none there is no base unit to start from, so the only units on offer are + // the ones its converters mention. + if ingredient.UnitID != nil { + baseUnit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to get base unit: %w", err) + } + + units = append(units, mappers.MapUnitEntityToResponse(baseUnit)) + unitMap[baseUnit.ID] = true + response.BaseUnitID = &baseUnit.ID + response.BaseUnitName = baseUnit.Name } // Get all converters for this ingredient @@ -283,10 +295,6 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex return nil, fmt.Errorf("failed to get converters: %w", err) } - // Add unique units from converters - unitMap := make(map[uuid.UUID]bool) - unitMap[baseUnit.ID] = true - for _, converter := range converters { if converter.IsActive { // Add FromUnit if not already added @@ -309,13 +317,7 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex } } - response := &models.IngredientUnitsResponse{ - IngredientID: ingredientID, - IngredientName: ingredient.Name, - BaseUnitID: baseUnit.ID, - BaseUnitName: baseUnit.Name, - Units: units, - } + response.Units = units return response, nil } diff --git a/internal/processor/order_ingredient_transaction_processor.go b/internal/processor/order_ingredient_transaction_processor.go index ba84237..0729b5f 100644 --- a/internal/processor/order_ingredient_transaction_processor.go +++ b/internal/processor/order_ingredient_transaction_processor.go @@ -371,8 +371,8 @@ func (p *OrderIngredientTransactionProcessorImpl) CalculateWasteQuantities(ctx c // Get unit name unitName := "unit" // default - if ingredient.UnitID != uuid.Nil { - unit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID) + if ingredient.UnitID != nil { + unit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID) if err == nil { unitName = unit.Name } diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index 3d0e150..945745a 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -462,13 +462,15 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err) } - // Convert quantity to ingredient's base unit if needed + // Convert quantity to ingredient's base unit if needed. An ingredient + // without a unit has no base unit to convert into, so the purchased + // quantity is taken as-is. quantityToAdd := *item.Quantity - if *item.UnitID != ingredient.UnitID { + if ingredient.UnitID != nil && *item.UnitID != *ingredient.UnitID { // Convert from purchase unit to ingredient's base unit - convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity) + convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, *ingredient.UnitID, organizationID, *item.Quantity) if err != nil { - return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err) + return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, *ingredient.UnitID, err) } quantityToAdd = convertedQuantity } diff --git a/migrations/000086_make_ingredients_unit_id_nullable.down.sql b/migrations/000086_make_ingredients_unit_id_nullable.down.sql new file mode 100644 index 0000000..a0de2d3 --- /dev/null +++ b/migrations/000086_make_ingredients_unit_id_nullable.down.sql @@ -0,0 +1,6 @@ +-- Restoring NOT NULL fails if any ingredient still has a NULL unit_id. Assign a +-- unit to those rows first: +-- SELECT id, name FROM ingredients WHERE unit_id IS NULL; +COMMENT ON COLUMN ingredients.unit_id IS NULL; + +ALTER TABLE ingredients ALTER COLUMN unit_id SET NOT NULL; diff --git a/migrations/000086_make_ingredients_unit_id_nullable.up.sql b/migrations/000086_make_ingredients_unit_id_nullable.up.sql new file mode 100644 index 0000000..ba15a45 --- /dev/null +++ b/migrations/000086_make_ingredients_unit_id_nullable.up.sql @@ -0,0 +1,5 @@ +-- An ingredient can be registered before its unit has been decided, so unit_id +-- is optional. Existing rows are untouched: they already have a unit. +ALTER TABLE ingredients ALTER COLUMN unit_id DROP NOT NULL; + +COMMENT ON COLUMN ingredients.unit_id IS 'Base unit of the ingredient. NULL means no unit has been assigned yet.'; -- 2.54.0 From dbc143954ce26fefa596e4852742c7b05f08ca44 Mon Sep 17 00:00:00 2001 From: efrilm Date: Tue, 11 Aug 2026 21:24:24 +0700 Subject: [PATCH 03/47] feat(purchase): unwired unit convertions --- .../processor/purchase_order_processor.go | 75 ++----------------- 1 file changed, 8 insertions(+), 67 deletions(-) diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index 945745a..867f833 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -32,6 +32,9 @@ type PurchaseOrderProcessorImpl struct { categoryRepo CategoryRepository unitRepo UnitRepository fileRepo FileRepository + // Kept wired but currently unused: purchase orders are a record of spending + // only, so nothing here moves stock or converts units. These stay so that + // tying purchases back to inventory is a change in one place. inventoryMovementService InventoryMovementService unitConverterRepo IngredientUnitConverterRepository } @@ -438,73 +441,11 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte fmt.Println("status:", po.Status) - // Check if status is changing to "received" and current status is not "received" - if status == "received" && po.Status != "received" { - // Get purchase order with items for inventory update - poWithItems, err := p.purchaseOrderRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to get purchase order with items: %w", err) - } - - // Update inventory for each item - for _, item := range poWithItems.Items { - if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense { - continue - } - - if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil { - return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID) - } - - // Get ingredient to find its base unit - ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID) - if err != nil { - return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err) - } - - // Convert quantity to ingredient's base unit if needed. An ingredient - // without a unit has no base unit to convert into, so the purchased - // quantity is taken as-is. - quantityToAdd := *item.Quantity - if ingredient.UnitID != nil && *item.UnitID != *ingredient.UnitID { - // Convert from purchase unit to ingredient's base unit - convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, *ingredient.UnitID, organizationID, *item.Quantity) - if err != nil { - return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, *ingredient.UnitID, err) - } - quantityToAdd = convertedQuantity - } - - // Calculate unit cost in ingredient's base unit - unitCost := 0.0 - if quantityToAdd > 0 { - unitCost = calculatePurchaseOrderItemTotal(item.Quantity, item.Amount) / quantityToAdd - } - - // Create inventory movement for ingredient purchase - reason := fmt.Sprintf("Purchase order %s received", po.PONumber) - referenceType := entities.InventoryMovementReferenceTypePurchaseOrder - referenceID := &id - - err = p.inventoryMovementService.CreateIngredientMovement( - ctx, - *item.IngredientID, - organizationID, - outletID, - userID, - entities.InventoryMovementTypePurchase, - quantityToAdd, - unitCost, - reason, - &referenceType, - referenceID, - &item.ID, - ) - if err != nil { - return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", *item.IngredientID, err) - } - } - } + // A purchase order is a record of spending only. Receiving one does not move + // ingredient stock, does not recalculate ingredient cost, and never converts + // units: the quantity and unit on an item are kept exactly as the user + // entered them. Raw material items are therefore treated the same way expense + // items already were, and the ingredient on an item is just a reference. // Update the purchase order status statusOutletID := po.OutletID -- 2.54.0 From e6078e3c0b7f0d06633c347b73d8a395d546b226 Mon Sep 17 00:00:00 2001 From: Efril Date: Tue, 11 Aug 2026 22:41:10 +0700 Subject: [PATCH 04/47] feat(purchase --- internal/constants/purchase_team.go | 7 ++ internal/contract/analytics_contract.go | 26 +++- internal/contract/purchase_order_contract.go | 15 ++- internal/entities/analytics.go | 25 ++++ internal/handler/purchase_order_handler.go | 4 + internal/models/analytics.go | 51 +++++++- internal/models/purchase_order.go | 1 + internal/processor/analytics_processor.go | 25 +++- .../processor/analytics_processor_test.go | 109 +++++++++++++++- internal/repository/analytics_repository.go | 119 +++++++++++++++++- .../repository/purchase_order_repository.go | 8 ++ internal/service/analytics_service.go | 4 + internal/service/analytics_service_test.go | 10 ++ internal/service/purchase_order_service.go | 14 +++ internal/transformer/analytics_transformer.go | 19 +++ .../transformer/analytics_transformer_test.go | 44 +++++++ .../transformer/purchase_order_transformer.go | 1 + .../validator/purchase_order_validator.go | 25 ++++ .../purchase_order_validator_test.go | 42 +++++++ 19 files changed, 532 insertions(+), 17 deletions(-) diff --git a/internal/constants/purchase_team.go b/internal/constants/purchase_team.go index c3cc147..e066381 100644 --- a/internal/constants/purchase_team.go +++ b/internal/constants/purchase_team.go @@ -9,4 +9,11 @@ const ( // PurchaseTeamCentralName is what Pusat is called in the picker. Pusat has no // row of its own, so the name lives here rather than in the database. PurchaseTeamCentralName = "Pusat" + + // PurchaseTeamNone is the value the list filter takes to ask for purchases + // that have not been charged to any team yet. + PurchaseTeamNone = "none" + + // PurchaseTeamNoneName labels those purchases in the reports. + PurchaseTeamNoneName = "Tanpa Team" ) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index b49fd58..918be35 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -88,15 +88,19 @@ type SalesAnalyticsData struct { type PurchasingAnalyticsRequest struct { OrganizationID uuid.UUID OutletID *string `form:"outlet_id,omitempty"` - DateFrom string `form:"date_from" validate:"required"` - DateTo string `form:"date_to" validate:"required"` - GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` + // Team narrows the report to one team: a parent category id, "central" for + // Pusat, or "none" for purchases charged to no team. Empty covers all teams. + Team string `form:"team,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` + GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` } type PurchasingAnalyticsResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletName *string `json:"outlet_name,omitempty"` + Team string `json:"team,omitempty"` DateFrom time.Time `json:"date_from"` DateTo time.Time `json:"date_to"` GroupBy string `json:"group_by"` @@ -104,6 +108,21 @@ type PurchasingAnalyticsResponse struct { Data []PurchasingAnalyticsData `json:"data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` + TeamData []PurchasingTeamData `json:"team_data"` +} + +// PurchasingTeamData is one team's share of the purchases. Scope and CategoryID +// are exactly what the team filter takes, so a row doubles as a drill-down link. +type PurchasingTeamData struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` + TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + Quantity float64 `json:"quantity"` + Percentage float64 `json:"percentage"` } type PurchasingSummary struct { @@ -117,6 +136,7 @@ type PurchasingSummary struct { AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` TotalVendors int64 `json:"total_vendors"` + TotalTeams int64 `json:"total_teams"` } type PurchasingAnalyticsData struct { diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 79b14b2..26bbd52 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -114,11 +114,16 @@ type PurchaseOrderAttachmentResponse struct { } type ListPurchaseOrdersRequest struct { - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` - Search string `json:"search,omitempty"` - Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` - VendorID *uuid.UUID `json:"vendor_id,omitempty"` + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Search string `json:"search,omitempty"` + Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` + VendorID *uuid.UUID `json:"vendor_id,omitempty"` + // Team is the single-value form of the two filters below, so the team picker + // can send back what it was given: a parent category id, "central" for Pusat, + // or "none" for purchases with no team yet. It replaces them rather than + // narrowing alongside them. + Team string `json:"team,omitempty"` TeamScope string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"` TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` StartDate *time.Time `json:"start_date,omitempty"` diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 4ea0933..fc38018 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -27,6 +27,14 @@ type SalesAnalytics struct { NetSales float64 `json:"net_sales"` } +// PurchaseTeamFilter narrows purchasing figures to a single team: a parent +// category, Pusat, or the purchases that carry no team at all. A nil filter +// leaves the figures spanning every team. +type PurchaseTeamFilter struct { + Scope string + CategoryID *uuid.UUID +} + // PurchasingAnalytics represents purchasing analytics data type PurchasingAnalytics struct { OutletName *string `json:"outlet_name,omitempty"` @@ -34,6 +42,22 @@ type PurchasingAnalytics struct { Data []PurchasingAnalyticsData `json:"data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` + TeamData []PurchasingTeamData `json:"team_data"` +} + +// PurchasingTeamData is one team's share of the purchases: a parent category, +// Pusat, or the purchases charged to no team at all. Scope and CategoryID are +// what the team filter takes back, so a row can be clicked straight through. +type PurchasingTeamData struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` + TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + Quantity float64 `json:"quantity"` + Percentage float64 `json:"percentage"` } type PurchasingSummary struct { @@ -47,6 +71,7 @@ type PurchasingSummary struct { AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` TotalVendors int64 `json:"total_vendors"` + TotalTeams int64 `json:"total_teams"` } type PurchasingAnalyticsData struct { diff --git a/internal/handler/purchase_order_handler.go b/internal/handler/purchase_order_handler.go index b210fd0..f1d1012 100644 --- a/internal/handler/purchase_order_handler.go +++ b/internal/handler/purchase_order_handler.go @@ -176,6 +176,10 @@ func (h *PurchaseOrderHandler) ListPurchaseOrders(c *gin.Context) { } } + if team := c.Query("team"); team != "" { + req.Team = team + } + if teamScope := c.Query("team_scope"); teamScope != "" { req.TeamScope = teamScope } diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 60b511d..d3aaa07 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -1,8 +1,12 @@ package models import ( + "fmt" "time" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "github.com/google/uuid" ) @@ -93,9 +97,34 @@ type SalesAnalyticsData struct { type PurchasingAnalyticsRequest struct { OrganizationID uuid.UUID `validate:"required"` OutletID *uuid.UUID `validate:"omitempty"` - DateFrom time.Time `validate:"required"` - DateTo time.Time `validate:"required"` - GroupBy string `validate:"omitempty,oneof=day hour week month"` + // Team is the raw value the team picker sends: a parent category id, + // "central" for Pusat, "none" for purchases with no team, or empty for all. + Team string + DateFrom time.Time `validate:"required"` + DateTo time.Time `validate:"required"` + GroupBy string `validate:"omitempty,oneof=day hour week month"` +} + +// ParsePurchaseTeamFilter turns the team value the picker sends into the scope and +// category the purchasing queries filter on. An empty value spans every team; an +// unknown one is an error rather than a report that quietly ignores the filter. +func ParsePurchaseTeamFilter(team string) (*entities.PurchaseTeamFilter, error) { + switch team { + case "": + return nil, nil + case constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone: + return &entities.PurchaseTeamFilter{Scope: team}, nil + } + + categoryID, err := uuid.Parse(team) + if err != nil || categoryID == uuid.Nil { + return nil, fmt.Errorf("team must be one of: central, none, or a category id") + } + + return &entities.PurchaseTeamFilter{ + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + }, nil } // PurchasingAnalyticsResponse represents the response for purchasing analytics @@ -103,6 +132,7 @@ type PurchasingAnalyticsResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletName *string `json:"outlet_name,omitempty"` + Team string `json:"team,omitempty"` DateFrom time.Time `json:"date_from"` DateTo time.Time `json:"date_to"` GroupBy string `json:"group_by"` @@ -110,6 +140,20 @@ type PurchasingAnalyticsResponse struct { Data []PurchasingAnalyticsData `json:"data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` + TeamData []PurchasingTeamData `json:"team_data"` +} + +// PurchasingTeamData represents purchasing analytics for a single team +type PurchasingTeamData struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` + TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + Quantity float64 `json:"quantity"` + Percentage float64 `json:"percentage"` } // PurchasingSummary represents the summary of purchasing analytics @@ -124,6 +168,7 @@ type PurchasingSummary struct { AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` TotalVendors int64 `json:"total_vendors"` + TotalTeams int64 `json:"total_teams"` } // PurchasingAnalyticsData represents purchasing analytics by time period diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index e5e1287..6ac202e 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -152,6 +152,7 @@ type ListPurchaseOrdersRequest struct { Search string `json:"search,omitempty"` Status string `json:"status,omitempty"` VendorID *uuid.UUID `json:"vendor_id,omitempty"` + Team string `json:"team,omitempty"` TeamScope string `json:"team_scope,omitempty"` TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` StartDate *time.Time `json:"start_date,omitempty"` diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 2560124..37ad289 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -200,7 +200,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req req.GroupBy = "day" } - result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy) + teamFilter, err := models.ParsePurchaseTeamFilter(req.Team) + if err != nil { + return nil, err + } + + result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, teamFilter, req.DateFrom, req.DateTo, req.GroupBy) if err != nil { return nil, fmt.Errorf("failed to get purchasing analytics: %w", err) } @@ -245,10 +250,26 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req } } + teamData := make([]models.PurchasingTeamData, len(result.TeamData)) + for i, item := range result.TeamData { + teamData[i] = models.PurchasingTeamData{ + Scope: item.Scope, + CategoryID: item.CategoryID, + Name: item.Name, + TotalPurchases: item.TotalPurchases, + RawMaterialPurchases: item.RawMaterialPurchases, + ExpensePurchases: item.ExpensePurchases, + PurchaseOrderCount: item.PurchaseOrderCount, + Quantity: item.Quantity, + Percentage: item.Percentage, + } + } + return &models.PurchasingAnalyticsResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, OutletName: result.OutletName, + Team: req.Team, DateFrom: req.DateFrom, DateTo: req.DateTo, GroupBy: req.GroupBy, @@ -263,10 +284,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue, TotalIngredients: result.Summary.TotalIngredients, TotalVendors: result.Summary.TotalVendors, + TotalTeams: result.Summary.TotalTeams, }, Data: data, IngredientData: ingredientData, VendorData: vendorData, + TeamData: teamData, }, nil } diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 6cb4722..7ae2d0e 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" @@ -14,6 +15,7 @@ import ( type analyticsRepositoryStub struct { purchasingResult *entities.PurchasingAnalytics + purchasingTeam *entities.PurchaseTeamFilter budgetCutOffWeeks []*entities.BudgetCutOffWeek profitLossResult *entities.ProfitLossAnalytics exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics @@ -32,7 +34,8 @@ func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uu return nil, nil } -func (s analyticsRepositoryStub) GetPurchasingAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.PurchasingAnalytics, error) { +func (s *analyticsRepositoryStub) GetPurchasingAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, team *entities.PurchaseTeamFilter, _, _ time.Time, _ string) (*entities.PurchasingAnalytics, error) { + s.purchasingTeam = team return s.purchasingResult, nil } @@ -158,6 +161,110 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } +func TestAnalyticsProcessorGetPurchasingAnalyticsPassesTeamFilter(t *testing.T) { + categoryID := uuid.New() + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + team string + want *entities.PurchaseTeamFilter + }{ + {name: "all teams", team: "", want: nil}, + {name: "pusat", team: constants.PurchaseTeamScopeCentral, want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamScopeCentral}}, + {name: "no team", team: constants.PurchaseTeamNone, want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamNone}}, + { + name: "category team", + team: categoryID.String(), + want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamScopeCategory, CategoryID: &categoryID}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &analyticsRepositoryStub{purchasingResult: &entities.PurchasingAnalytics{}} + processor := NewAnalyticsProcessorImpl(repo, expenseRepositoryStub{}) + + result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + Team: tt.team, + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.Equal(t, tt.team, result.Team) + require.Equal(t, tt.want, repo.purchasingTeam) + }) + } +} + +func TestAnalyticsProcessorGetPurchasingAnalyticsMapsTeamBreakdown(t *testing.T) { + categoryID := uuid.New() + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ + purchasingResult: &entities.PurchasingAnalytics{ + Summary: entities.PurchasingSummary{TotalPurchases: 300, TotalTeams: 2}, + TeamData: []entities.PurchasingTeamData{ + { + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + Name: "Kitchen", + TotalPurchases: 200, + RawMaterialPurchases: 150, + ExpensePurchases: 50, + PurchaseOrderCount: 2, + Quantity: 12, + Percentage: 66.67, + }, + { + Scope: constants.PurchaseTeamNone, + Name: constants.PurchaseTeamNoneName, + TotalPurchases: 100, + PurchaseOrderCount: 1, + Percentage: 33.33, + }, + }, + }, + }, expenseRepositoryStub{}) + + result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.Equal(t, int64(2), result.Summary.TotalTeams) + require.Len(t, result.TeamData, 2) + require.Equal(t, constants.PurchaseTeamScopeCategory, result.TeamData[0].Scope) + require.Equal(t, &categoryID, result.TeamData[0].CategoryID) + require.Equal(t, "Kitchen", result.TeamData[0].Name) + require.Equal(t, float64(200), result.TeamData[0].TotalPurchases) + require.Equal(t, float64(150), result.TeamData[0].RawMaterialPurchases) + require.Equal(t, 66.67, result.TeamData[0].Percentage) + require.Equal(t, constants.PurchaseTeamNone, result.TeamData[1].Scope) + require.Nil(t, result.TeamData[1].CategoryID) + require.Equal(t, constants.PurchaseTeamNoneName, result.TeamData[1].Name) +} + +func TestAnalyticsProcessorGetPurchasingAnalyticsRejectsUnknownTeam(t *testing.T) { + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + repo := &analyticsRepositoryStub{purchasingResult: &entities.PurchasingAnalytics{}} + processor := NewAnalyticsProcessorImpl(repo, expenseRepositoryStub{}) + + result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + Team: "marketing", + DateFrom: now, + DateTo: now, + }) + + require.Nil(t, result) + require.Error(t, err) + require.Contains(t, err.Error(), "team must be one of") +} + func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) { productID := uuid.New() categoryID := uuid.New() diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index a1d6a86..9d83d16 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -6,6 +6,7 @@ import ( "sort" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "github.com/google/uuid" @@ -15,7 +16,7 @@ import ( type AnalyticsRepository interface { GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) GetSalesAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]*entities.SalesAnalytics, error) - GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) + GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error) GetProductAnalyticsPerParentCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) @@ -159,7 +160,7 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz return results, err } -func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { +func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { var outletName *string if outletID != nil { @@ -179,10 +180,10 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or outletName = &outlet.Name } } - return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, outletName, dateFrom, dateTo, groupBy) + return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, team, outletName, dateFrom, dateTo, groupBy) } -func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { +func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { var summary entities.PurchasingSummary summaryQuery := r.db.WithContext(ctx). Table("purchase_orders po"). @@ -210,6 +211,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID) + summaryQuery = r.applyPurchaseOrderTeamFilter(summaryQuery, team) if err := summaryQuery.Scan(&summary).Error; err != nil { return nil, err @@ -253,6 +255,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Group(dateFormat). Order(dateFormat) dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID) + dataQuery = r.applyPurchaseOrderTeamFilter(dataQuery, team) if err := dataQuery.Scan(&data).Error; err != nil { return nil, err @@ -283,6 +286,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Group("i.id, i.name"). Order("total_cost DESC") ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID) + ingredientQuery = r.applyPurchaseOrderTeamFilter(ingredientQuery, team) if err := ingredientQuery.Scan(&ingredientData).Error; err != nil { return nil, err @@ -310,20 +314,105 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Group("v.id, COALESCE(v.name, 'No Vendor')"). Order("total_cost DESC") vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID) + vendorQuery = r.applyPurchaseOrderTeamFilter(vendorQuery, team) if err := vendorQuery.Scan(&vendorData).Error; err != nil { return nil, err } + teamData, err := r.getPurchaseOrderTeamBreakdown(ctx, organizationID, outletID, team, dateFrom, dateTo, summary.TotalPurchases) + if err != nil { + return nil, err + } + summary.TotalTeams = int64(len(teamData)) + return &entities.PurchasingAnalytics{ OutletName: outletName, Summary: summary, Data: data, IngredientData: ingredientData, VendorData: vendorData, + TeamData: teamData, }, nil } +// getPurchaseOrderTeamBreakdown splits the purchases over the teams they were +// charged to. Purchases with no team are kept as their own row rather than +// dropped, so the rows still add up to the summary total. +func (r *AnalyticsRepositoryImpl) getPurchaseOrderTeamBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, totalPurchases float64) ([]entities.PurchasingTeamData, error) { + var rows []struct { + Scope *string + CategoryID *uuid.UUID + CategoryName *string + TotalPurchases float64 + RawMaterialPurchases float64 + ExpensePurchases float64 + PurchaseOrderCount int64 + Quantity float64 + } + + query := r.db.WithContext(ctx). + Table("purchase_orders po"). + Select(` + po.team_scope as scope, + po.team_category_id as category_id, + c.name as category_name, + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_purchases, + COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases, + COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases, + COUNT(DISTINCT po.id) as purchase_order_count, + COALESCE(SUM(poi.quantity), 0) as quantity + `). + Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Joins("LEFT JOIN categories c ON po.team_category_id = c.id"). + Where("po.organization_id = ?", organizationID). + Where("po.status != ?", "cancelled"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). + Group("po.team_scope, po.team_category_id, c.name"). + Order("total_purchases DESC") + query = r.applyPurchaseOrderItemOutletFilter(query, outletID) + query = r.applyPurchaseOrderTeamFilter(query, team) + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + teamData := make([]entities.PurchasingTeamData, len(rows)) + for i, row := range rows { + entry := entities.PurchasingTeamData{ + CategoryID: row.CategoryID, + TotalPurchases: row.TotalPurchases, + RawMaterialPurchases: row.RawMaterialPurchases, + ExpensePurchases: row.ExpensePurchases, + PurchaseOrderCount: row.PurchaseOrderCount, + Quantity: row.Quantity, + } + + switch { + case row.Scope == nil: + entry.Scope = constants.PurchaseTeamNone + entry.Name = constants.PurchaseTeamNoneName + case *row.Scope == constants.PurchaseTeamScopeCentral: + entry.Scope = constants.PurchaseTeamScopeCentral + entry.Name = constants.PurchaseTeamCentralName + default: + entry.Scope = *row.Scope + if row.CategoryName != nil { + entry.Name = *row.CategoryName + } + } + + if totalPurchases != 0 { + entry.Percentage = row.TotalPurchases / totalPurchases * 100 + } + + teamData[i] = entry + } + + return teamData, nil +} + func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm.DB, outletID *uuid.UUID) *gorm.DB { if outletID == nil { return query @@ -331,6 +420,28 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm return query.Where("po.outlet_id = ?", *outletID) } +// applyPurchaseOrderTeamFilter narrows a purchase order query to the team the +// report asked for. A nil filter, or a category team without a category, leaves +// the query spanning every team. +func (r *AnalyticsRepositoryImpl) applyPurchaseOrderTeamFilter(query *gorm.DB, team *entities.PurchaseTeamFilter) *gorm.DB { + if team == nil { + return query + } + + switch team.Scope { + case constants.PurchaseTeamNone: + return query.Where("po.team_scope IS NULL") + case constants.PurchaseTeamScopeCentral: + return query.Where("po.team_scope = ?", constants.PurchaseTeamScopeCentral) + case constants.PurchaseTeamScopeCategory: + if team.CategoryID != nil { + return query.Where("po.team_scope = ? AND po.team_category_id = ?", constants.PurchaseTeamScopeCategory, *team.CategoryID) + } + } + + return query +} + func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) { var results []*entities.ProductAnalytics diff --git a/internal/repository/purchase_order_repository.go b/internal/repository/purchase_order_repository.go index 3dd71a2..d88694f 100644 --- a/internal/repository/purchase_order_repository.go +++ b/internal/repository/purchase_order_repository.go @@ -101,6 +101,10 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u if teamCategoryID, ok := value.(uuid.UUID); ok { query = query.Where("team_category_id = ?", teamCategoryID) } + case "team_unassigned": + if unassigned, ok := value.(bool); ok && unassigned { + query = query.Where("team_scope IS NULL") + } case "start_date": if startDate, ok := value.(time.Time); ok { query = query.Where("transaction_date >= ?", startDate) @@ -160,6 +164,10 @@ func (r *PurchaseOrderRepositoryImpl) Count(ctx context.Context, organizationID if teamCategoryID, ok := value.(uuid.UUID); ok { query = query.Where("team_category_id = ?", teamCategoryID) } + case "team_unassigned": + if unassigned, ok := value.(bool); ok && unassigned { + query = query.Where("team_scope IS NULL") + } case "start_date": if startDate, ok := value.(time.Time); ok { query = query.Where("transaction_date >= ?", startDate) diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 889b766..3ddb1a3 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -238,6 +238,10 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu } } + if _, err := models.ParsePurchaseTeamFilter(req.Team); err != nil { + return err + } + return nil } diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index e300d77..c67a864 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -113,6 +113,16 @@ func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) { }, wantErr: "invalid group_by value: quarter", }, + { + name: "unknown team", + req: &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + Team: "marketing", + }, + wantErr: "team must be one of", + }, } for _, tt := range tests { diff --git a/internal/service/purchase_order_service.go b/internal/service/purchase_order_service.go index d6d4458..b10c8d8 100644 --- a/internal/service/purchase_order_service.go +++ b/internal/service/purchase_order_service.go @@ -120,6 +120,20 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx if modelReq.TeamCategoryID != nil { filters["team_category_id"] = *modelReq.TeamCategoryID } + // team spells out the same two filters in one value; the validator has already + // ruled out sending it together with them. + switch modelReq.Team { + case "": + case constants.PurchaseTeamNone: + filters["team_unassigned"] = true + case constants.PurchaseTeamScopeCentral: + filters["team_scope"] = constants.PurchaseTeamScopeCentral + default: + if teamCategoryID, err := uuid.Parse(modelReq.Team); err == nil { + filters["team_scope"] = constants.PurchaseTeamScopeCategory + filters["team_category_id"] = teamCategoryID + } + } if modelReq.StartDate != nil { filters["start_date"] = *modelReq.StartDate } diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 14d008f..d9214eb 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -156,6 +156,7 @@ func PurchasingAnalyticsContractToModel(req *contract.PurchasingAnalyticsRequest return &models.PurchasingAnalyticsRequest{ OrganizationID: req.OrganizationID, OutletID: parseOutletID(req.OutletID), + Team: req.Team, DateFrom: dateFrom, DateTo: dateTo, GroupBy: req.GroupBy, @@ -208,10 +209,26 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse } } + teamData := make([]contract.PurchasingTeamData, len(resp.TeamData)) + for i, item := range resp.TeamData { + teamData[i] = contract.PurchasingTeamData{ + Scope: item.Scope, + CategoryID: item.CategoryID, + Name: item.Name, + TotalPurchases: item.TotalPurchases, + RawMaterialPurchases: item.RawMaterialPurchases, + ExpensePurchases: item.ExpensePurchases, + PurchaseOrderCount: item.PurchaseOrderCount, + Quantity: item.Quantity, + Percentage: item.Percentage, + } + } + return &contract.PurchasingAnalyticsResponse{ OrganizationID: resp.OrganizationID, OutletID: resp.OutletID, OutletName: resp.OutletName, + Team: resp.Team, DateFrom: resp.DateFrom, DateTo: resp.DateTo, GroupBy: resp.GroupBy, @@ -226,10 +243,12 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue, TotalIngredients: resp.Summary.TotalIngredients, TotalVendors: resp.Summary.TotalVendors, + TotalTeams: resp.Summary.TotalTeams, }, Data: data, IngredientData: ingredientData, VendorData: vendorData, + TeamData: teamData, } } diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index 4d1327e..300decc 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/models" @@ -95,6 +96,49 @@ func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) { require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } +func TestPurchasingAnalyticsModelToContractCopiesTeamData(t *testing.T) { + categoryID := uuid.New() + + result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ + OrganizationID: uuid.New(), + Team: categoryID.String(), + Summary: models.PurchasingSummary{TotalPurchases: 300, TotalTeams: 2}, + TeamData: []models.PurchasingTeamData{ + { + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + Name: "Kitchen", + TotalPurchases: 200, + RawMaterialPurchases: 150, + ExpensePurchases: 50, + PurchaseOrderCount: 2, + Quantity: 12, + Percentage: 66.67, + }, + { + Scope: constants.PurchaseTeamNone, + Name: constants.PurchaseTeamNoneName, + TotalPurchases: 100, + PurchaseOrderCount: 1, + Percentage: 33.33, + }, + }, + }) + + require.NotNil(t, result) + require.Equal(t, categoryID.String(), result.Team) + require.Equal(t, int64(2), result.Summary.TotalTeams) + require.Len(t, result.TeamData, 2) + require.Equal(t, constants.PurchaseTeamScopeCategory, result.TeamData[0].Scope) + require.Equal(t, &categoryID, result.TeamData[0].CategoryID) + require.Equal(t, "Kitchen", result.TeamData[0].Name) + require.Equal(t, float64(200), result.TeamData[0].TotalPurchases) + require.Equal(t, 66.67, result.TeamData[0].Percentage) + require.Equal(t, constants.PurchaseTeamNone, result.TeamData[1].Scope) + require.Nil(t, result.TeamData[1].CategoryID) + require.Equal(t, constants.PurchaseTeamNoneName, result.TeamData[1].Name) +} + func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) { result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ OrganizationID: uuid.New(), diff --git a/internal/transformer/purchase_order_transformer.go b/internal/transformer/purchase_order_transformer.go index 704e7e8..962ec19 100644 --- a/internal/transformer/purchase_order_transformer.go +++ b/internal/transformer/purchase_order_transformer.go @@ -110,6 +110,7 @@ func ListPurchaseOrdersRequestToModel(req *contract.ListPurchaseOrdersRequest) * Search: req.Search, Status: req.Status, VendorID: req.VendorID, + Team: req.Team, TeamScope: req.TeamScope, TeamCategoryID: req.TeamCategoryID, StartDate: req.StartDate, diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index 3d16631..1de3855 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -193,6 +193,21 @@ func validatePurchaseTeamSelection(scope *string, categoryID *uuid.UUID, allowCl return nil, "" } +// validatePurchaseTeamFilter accepts the values the team picker hands back: Pusat, +// no team at all, or the id of the parent category a purchase is charged to. +func validatePurchaseTeamFilter(team string) (error, string) { + switch team { + case constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone: + return nil, "" + } + + if categoryID, err := uuid.Parse(team); err != nil || categoryID == uuid.Nil { + return errors.New("team must be one of: central, none, or a category id"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *contract.ListPurchaseOrdersRequest) (error, string) { if req == nil { return errors.New("request body is required"), constants.MissingFieldErrorCode @@ -213,6 +228,16 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont } } + if req.Team != "" { + if req.TeamScope != "" || req.TeamCategoryID != nil { + return errors.New("team cannot be combined with team_scope or team_category_id"), constants.MalformedFieldErrorCode + } + + if err, code := validatePurchaseTeamFilter(req.Team); err != nil { + return err, code + } + } + if req.TeamScope != "" { validScopes := []string{constants.PurchaseTeamScopeCategory, constants.PurchaseTeamScopeCentral} if !contains(validScopes, req.TeamScope) { diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go index 32a8eac..f7a7914 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -195,6 +195,48 @@ func TestPurchaseOrderValidatorUpdateRejectsClearingTeamWithCategory(t *testing. require.Equal(t, constants.MalformedFieldErrorCode, code) } +func TestPurchaseOrderValidatorListAcceptsTeamFilter(t *testing.T) { + validator := NewPurchaseOrderValidator() + + for _, team := range []string{constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone, uuid.New().String()} { + err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{ + Page: 1, + Limit: 10, + Team: team, + }) + + require.NoError(t, err, team) + require.Empty(t, code, team) + } +} + +func TestPurchaseOrderValidatorListRejectsUnknownTeamFilter(t *testing.T) { + validator := NewPurchaseOrderValidator() + + err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{ + Page: 1, + Limit: 10, + Team: "marketing", + }) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) +} + +func TestPurchaseOrderValidatorListRejectsTeamWithScope(t *testing.T) { + validator := NewPurchaseOrderValidator() + + err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{ + Page: 1, + Limit: 10, + Team: constants.PurchaseTeamNone, + TeamScope: constants.PurchaseTeamScopeCentral, + }) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) +} + func TestPurchaseOrderValidatorListRejectsCentralScopeWithCategory(t *testing.T) { validator := NewPurchaseOrderValidator() categoryID := uuid.New() -- 2.54.0 From 2c6864147b468441a1df8e61f5d98d45d6567cf6 Mon Sep 17 00:00:00 2001 From: Efril Date: Thu, 13 Aug 2026 14:38:28 +0700 Subject: [PATCH 05/47] feat: cash advance --- README.md | 206 ++++++++----- internal/app/app.go | 14 +- internal/constants/cash_advance.go | 62 ++++ internal/constants/error.go | 1 + internal/contract/cash_advance_contract.go | 92 ++++++ internal/contract/expense_contract.go | 44 +-- internal/contract/purchase_order_contract.go | 28 +- internal/entities/cash_advance.go | 63 ++++ internal/entities/entities.go | 1 + internal/entities/expense.go | 7 +- internal/entities/purchase_order.go | 8 +- internal/handler/cash_advance_handler.go | 232 ++++++++++++++ internal/mappers/cash_advance_mapper.go | 128 ++++++++ internal/mappers/cash_advance_mapper_test.go | 101 +++++++ internal/mappers/expense_mapper.go | 1 + internal/mappers/purchase_order_mapper.go | 3 + internal/models/cash_advance.go | 104 +++++++ internal/models/expense.go | 3 + internal/models/purchase_order.go | 4 + internal/processor/cash_advance_processor.go | 286 ++++++++++++++++++ internal/processor/cash_advance_repository.go | 20 ++ internal/processor/expense_processor.go | 38 ++- internal/processor/expense_processor_test.go | 40 ++- .../processor/purchase_order_processor.go | 141 +++++---- internal/processor/team.go | 89 ++++++ .../repository/cash_advance_repository.go | 219 ++++++++++++++ internal/router/router.go | 17 +- internal/service/cash_advance_service.go | 180 +++++++++++ .../transformer/cash_advance_transformer.go | 126 ++++++++ internal/transformer/expense_transformer.go | 3 + .../transformer/purchase_order_transformer.go | 3 + internal/validator/cash_advance_validator.go | 192 ++++++++++++ ...000087_create_cash_advances_table.down.sql | 1 + .../000087_create_cash_advances_table.up.sql | 45 +++ ...0088_add_cash_advance_to_spending.down.sql | 15 + ...000088_add_cash_advance_to_spending.up.sql | 24 ++ 36 files changed, 2355 insertions(+), 186 deletions(-) create mode 100644 internal/constants/cash_advance.go create mode 100644 internal/contract/cash_advance_contract.go create mode 100644 internal/entities/cash_advance.go create mode 100644 internal/handler/cash_advance_handler.go create mode 100644 internal/mappers/cash_advance_mapper.go create mode 100644 internal/mappers/cash_advance_mapper_test.go create mode 100644 internal/models/cash_advance.go create mode 100644 internal/processor/cash_advance_processor.go create mode 100644 internal/processor/cash_advance_repository.go create mode 100644 internal/processor/team.go create mode 100644 internal/repository/cash_advance_repository.go create mode 100644 internal/service/cash_advance_service.go create mode 100644 internal/transformer/cash_advance_transformer.go create mode 100644 internal/validator/cash_advance_validator.go create mode 100644 migrations/000087_create_cash_advances_table.down.sql create mode 100644 migrations/000087_create_cash_advances_table.up.sql create mode 100644 migrations/000088_add_cash_advance_to_spending.down.sql create mode 100644 migrations/000088_add_cash_advance_to_spending.up.sql diff --git a/README.md b/README.md index 6623a96..c0cc7f1 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,19 @@ Makefile requires installed dependecies: ```shell $ make -Usage: make [command] +Usage: make [command] [ENV=staging|production] Commands: + run Run server (default: staging) + run ENV=production Run server with production config + rename-project name={name} Rename project - + build-http Build http server migration-create name={name} Create migration migration-up Up migrations + migration-up ENV=production Up migrations (production DB) migration-down Down last migration docker-up Up docker services @@ -36,24 +40,16 @@ Commands: ## HTTP Server -```shell -$ ./bin/http-server --help - -Usage: http-server - -Flags: - -h, --help Show mycontext-sensitive help. - --env-path=STRING Path to env config file -``` - -**Configuration** is based on the environment variables. See [.env.template](.env). +The server takes no CLI flags. It reads `ENV_MODE` and loads the matching YAML file from +[infra/](infra/) — see [Running the Application](#running-the-application) for details. ```shell -# Expose env vars before and start server -$ ./bin/http-server +# Build, then start with the staging config (default) +$ go build -o ./bin/http-server ./cmd/server/main.go +$ ENV_MODE=staging ./bin/http-server -# Expose env vars from the file and start server -$ ./bin/http-server --env-path ./config/env/.env +# Start with the production config +$ ENV_MODE=production ./bin/http-server ``` ## API Docs @@ -124,7 +120,7 @@ Handler → Service → Processor → Repository ## API Endpoints ### Health Check -- `GET /api/v1/health` - Health check endpoint +- `GET /health` - Health check endpoint (registered at the root, not under `/api/v1`) ### Organizations - `POST /api/v1/organizations` - Create organization @@ -157,73 +153,139 @@ Handler → Service → Processor → Repository - `PUT /api/v1/order-items/{id}` - Update order item - `DELETE /api/v1/order-items/{id}` - Remove order item -## Installation +## Running the Application -1. **Clone the repository** - ```bash - git clone - cd apskel-pos-backend - ``` +### Prerequisites -2. **Install dependencies** - ```bash - go mod tidy - ``` +| Tool | Version | Needed for | +|------|---------|------------| +| [Go](https://go.dev/doc/install) | 1.24+ | building & running the server | +| [golang-migrate](https://github.com/golang-migrate/migrate) | latest | `make migration-*` targets | +| [make](https://www.gnu.org/software/make/) | any | shortcut commands (Windows: use Git Bash / WSL, see note below) | +| [docker & docker-compose](https://docs.docker.com/compose/) | optional | running Postgres/Redis locally | +| [air](https://github.com/air-verse/air) | optional | hot reload during development (`.air.toml` is already configured) | -3. **Set up database** - ```bash - # Set your PostgreSQL database URL - export DATABASE_URL="postgres://username:password@localhost:5432/apskel_pos?sslmode=disable" - ``` - -4. **Run migrations** - ```bash - make migration-up - ``` - -## Usage - -### Development +### 1. Clone & install dependencies ```bash -# Start the server -go run cmd/server/main.go -port 8080 -db-url "postgres://username:password@localhost:5432/apskel_pos?sslmode=disable" - -# Or using environment variable -export DATABASE_URL="postgres://username:password@localhost:5432/apskel_pos?sslmode=disable" -go run cmd/server/main.go -port 8080 +git clone +cd apskel-pos-backend +go mod download ``` -### Using Make Commands +### 2. Configuration + +Configuration is **not** read from `.env` files — it is loaded from YAML files in [infra/](infra/) +by [config/configs.go](config/configs.go) using viper. + +The file is selected by the `ENV_MODE` environment variable: + +| `ENV_MODE` | Config file loaded | +|------------|--------------------| +| `local` | `infra/local.yaml` | +| `development` | `infra/development.yaml` | +| `staging` *(default)* | `infra/staging.yaml` | +| `production` | `infra/production.yaml` | + +Any other/unset value falls back to `staging`. Only `staging.yaml` and `production.yaml` are +committed — for `local`/`development` copy one of them first: ```bash -# Run the application -make start - -# Format code -make fmt - -# Run tests -make test - -# Build for production -make build-http - -# Docker operations -make docker-up -make docker-down - -# Database migrations -make migration-create name=create_users_table -make migration-up -make migration-down +cp infra/staging.yaml infra/local.yaml ``` +Two important notes: + +* The config path is **relative to the working directory**, so always run the server from the + repository root, otherwise viper panics with `failed to read config file`. +* Push notifications need `infra/firebase-service-account.json` (git-ignored). Without it, obtain + the file from the team before enabling FCM features. + +### 3. Run migrations + +The migration targets build the DB URL from the credentials at the top of the [Makefile](Makefile): + +```bash +make migration-up # staging DB (default) +make migration-up ENV=production # production DB + +make migration-create name=create_cash_advances_table +make migration-down # roll back the last migration +make migration-force version=87 # clear a dirty migration state +``` + +### 4. Start the server + +```bash +make run # ENV_MODE=staging +make run ENV=production # ENV_MODE=production +``` + +`make run` is just a wrapper around: + +```bash +ENV_MODE=staging go run cmd/server/main.go +``` + +The server listens on the `server.port` value from the loaded YAML (**4000** for both staging and +production). Verify it is up: + +```bash +curl http://localhost:4000/health +``` + +All application routes live under `/api/v1` (see [internal/router/router.go](internal/router/router.go)). + +#### Windows note + +The `run`/`start` targets use POSIX inline env-var syntax, which `cmd.exe` and PowerShell do not +understand. Either run `make` from Git Bash / WSL, or start the server directly: + +```powershell +# PowerShell +$env:ENV_MODE = "staging"; go run cmd/server/main.go +``` + +```cmd +:: cmd.exe +set ENV_MODE=staging && go run cmd/server/main.go +``` + +#### Hot reload + +```bash +ENV_MODE=local air # rebuilds ./tmp/main on every .go change +``` + +### 5. Other commands + +```bash +make # show all available targets +make fmt # go fmt ./... +make test # go test ./... -v + +# Build a binary (make build-http still points at the old ./cmd/http path) +go build -o ./bin/http-server ./cmd/server/main.go +``` + +### Running with Docker + +`docker-compose.yaml` provides Postgres (`5432`), Redis (`6379`), and the API image. See +[DOCKER.md](DOCKER.md) for the full workflow. + +```bash +make docker-up # docker-compose up -d +make docker-down # docker-compose down +``` + +If you use the containerised Postgres/Redis, point `infra/local.yaml` at `localhost:5432` / +`localhost:6379` instead of the remote hosts baked into `staging.yaml`. + ## Example API Usage ### Create Organization ```bash -curl -X POST http://localhost:8080/api/v1/organizations \ +curl -X POST http://localhost:4000/api/v1/organizations \ -H "Content-Type: application/json" \ -d '{ "name": "My Restaurant", @@ -233,7 +295,7 @@ curl -X POST http://localhost:8080/api/v1/organizations \ ### Create User ```bash -curl -X POST http://localhost:8080/api/v1/users \ +curl -X POST http://localhost:4000/api/v1/users \ -H "Content-Type: application/json" \ -d '{ "organization_id": "uuid-here", @@ -247,7 +309,7 @@ curl -X POST http://localhost:8080/api/v1/users \ ### Create Order with Items ```bash -curl -X POST http://localhost:8080/api/v1/orders \ +curl -X POST http://localhost:4000/api/v1/orders \ -H "Content-Type: application/json" \ -d '{ "outlet_id": "uuid-here", diff --git a/internal/app/app.go b/internal/app/app.go index 42dd639..ace698f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -140,6 +140,8 @@ func (a *App) Initialize(cfg *config.Config) error { selfOrderHandler, services.expenseService, validators.expenseValidator, + services.cashAdvanceService, + validators.cashAdvanceValidator, a.redisClient, ) @@ -244,6 +246,7 @@ type repositories struct { notificationDeliveryRepo *repository.NotificationDeliveryRepositoryImpl productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl expenseRepo *repository.ExpenseRepositoryImpl + cashAdvanceRepo *repository.CashAdvanceRepositoryImpl } func (a *App) initRepositories() *repositories { @@ -298,6 +301,7 @@ func (a *App) initRepositories() *repositories { notificationDeliveryRepo: repository.NewNotificationDeliveryRepository(a.db), productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db), expenseRepo: repository.NewExpenseRepositoryImpl(a.db), + cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db), } } @@ -345,6 +349,7 @@ type processors struct { notificationProcessor *processor.NotificationProcessorImpl productOutletPriceProcessor processor.ProductOutletPriceProcessor expenseProcessor *processor.ExpenseProcessorImpl + cashAdvanceProcessor *processor.CashAdvanceProcessorImpl } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -372,7 +377,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo), productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo), vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo), - purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.categoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), + purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.categoryRepo, repos.cashAdvanceRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo), unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo), chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo), @@ -396,7 +401,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo), notificationProcessor: buildNotificationProcessor(cfg, repos), productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo), - expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo), + expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo), + cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo), } } @@ -438,6 +444,7 @@ type services struct { notificationService service.NotificationService productOutletPriceService service.ProductOutletPriceService expenseService *service.ExpenseServiceImpl + cashAdvanceService *service.CashAdvanceServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -518,6 +525,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con notificationService: notificationService, productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor), expenseService: service.NewExpenseService(processors.expenseProcessor), + cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor), } } @@ -562,6 +570,7 @@ type validators struct { notificationValidator *validator.NotificationValidatorImpl productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl expenseValidator *validator.ExpenseValidatorImpl + cashAdvanceValidator *validator.CashAdvanceValidatorImpl } func (a *App) initValidators() *validators { @@ -594,6 +603,7 @@ func (a *App) initValidators() *validators { notificationValidator: validator.NewNotificationValidator(), productOutletPriceValidator: validator.NewProductOutletPriceValidator(), expenseValidator: validator.NewExpenseValidator(), + cashAdvanceValidator: validator.NewCashAdvanceValidator(), } } diff --git a/internal/constants/cash_advance.go b/internal/constants/cash_advance.go new file mode 100644 index 0000000..d964406 --- /dev/null +++ b/internal/constants/cash_advance.go @@ -0,0 +1,62 @@ +package constants + +// A cash advance is money handed to a team so it can go shopping. Its status is +// about the document only — whether the money may leave the drawer. How much of it +// has been accounted for is a separate axis, derived from the spending charged to +// the advance rather than stored, so the two never have to be kept in step. +const ( + CashAdvanceStatusDraft = "draft" + CashAdvanceStatusApproved = "approved" + CashAdvanceStatusRejected = "rejected" + CashAdvanceStatusCancelled = "cancelled" +) + +// Settlement states come out of the amount, the spending charged to the advance, and +// the cash handed back. An advance the team overspent still counts as settled: the +// shortfall is owed back to the team and shows up as a negative remaining amount. +const ( + CashAdvanceSettlementOpen = "open" + CashAdvanceSettlementPartial = "partial" + CashAdvanceSettlementSettled = "settled" +) + +func GetAllCashAdvanceStatuses() []string { + return []string{ + CashAdvanceStatusDraft, + CashAdvanceStatusApproved, + CashAdvanceStatusRejected, + CashAdvanceStatusCancelled, + } +} + +func IsValidCashAdvanceStatus(status string) bool { + for _, valid := range GetAllCashAdvanceStatuses() { + if status == valid { + return true + } + } + return false +} + +func GetAllCashAdvanceSettlementStatuses() []string { + return []string{ + CashAdvanceSettlementOpen, + CashAdvanceSettlementPartial, + CashAdvanceSettlementSettled, + } +} + +func IsValidCashAdvanceSettlementStatus(status string) bool { + for _, valid := range GetAllCashAdvanceSettlementStatuses() { + if status == valid { + return true + } + } + return false +} + +// Settlement entries name where a piece of spending was recorded. +const ( + CashAdvanceSettlementTypePurchaseOrder = "purchase_order" + CashAdvanceSettlementTypeExpense = "expense" +) diff --git a/internal/constants/error.go b/internal/constants/error.go index af56665..c2cd49b 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -62,6 +62,7 @@ const ( NotificationHandlerEntity = "notification_handler" ProductOutletPriceServiceEntity = "product_outlet_price_service" ExpenseServiceEntity = "expense_service" + CashAdvanceServiceEntity = "cash_advance_service" ) var HttpErrorMap = map[string]int{ diff --git a/internal/contract/cash_advance_contract.go b/internal/contract/cash_advance_contract.go new file mode 100644 index 0000000..47bf20a --- /dev/null +++ b/internal/contract/cash_advance_contract.go @@ -0,0 +1,92 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +type CreateCashAdvanceRequest struct { + // OutletID falls back to the caller's outlet when omitted; an advance is cash out + // of one drawer, so one of the two has to be known. + OutletID *uuid.UUID `json:"outlet_id,omitempty" validate:"omitempty"` + CodeNumber string `json:"code_number" validate:"required,min=1,max=50"` + TeamScope string `json:"team_scope" validate:"required,oneof=category central"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"` + Amount float64 `json:"amount" validate:"required,gt=0"` + IssuedDate string `json:"issued_date" validate:"required"` // Format: YYYY-MM-DD + DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD + Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft approved rejected cancelled"` + Description *string `json:"description,omitempty" validate:"omitempty"` +} + +type UpdateCashAdvanceRequest struct { + CodeNumber *string `json:"code_number,omitempty" validate:"omitempty,min=1,max=50"` + // An advance always belongs to a team, so team_scope can be moved but not cleared. + TeamScope *string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"` + Amount *float64 `json:"amount,omitempty" validate:"omitempty,gt=0"` + ReturnedAmount *float64 `json:"returned_amount,omitempty" validate:"omitempty,gte=0"` + IssuedDate *string `json:"issued_date,omitempty" validate:"omitempty"` + DueDate *string `json:"due_date,omitempty" validate:"omitempty"` + Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft approved rejected cancelled"` + Description *string `json:"description,omitempty" validate:"omitempty"` +} + +type CashAdvanceResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID uuid.UUID `json:"outlet_id"` + CodeNumber string `json:"code_number"` + TeamScope string `json:"team_scope"` + TeamCategoryID *uuid.UUID `json:"team_category_id"` + Amount float64 `json:"amount"` + SettledAmount float64 `json:"settled_amount"` + ReturnedAmount float64 `json:"returned_amount"` + RemainingAmount float64 `json:"remaining_amount"` + SettlementStatus string `json:"settlement_status"` + IssuedDate time.Time `json:"issued_date"` + DueDate *time.Time `json:"due_date"` + Status string `json:"status"` + Description *string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Team *PurchaseTeamResponse `json:"team,omitempty"` + Settlements []CashAdvanceSettlementResponse `json:"settlements,omitempty"` +} + +// CashAdvanceSettlementResponse is one purchase order or expense paid out of the +// advance. +type CashAdvanceSettlementResponse struct { + Type string `json:"type"` + ID uuid.UUID `json:"id"` + Number string `json:"number"` + Date time.Time `json:"date"` + Amount float64 `json:"amount"` + Status string `json:"status"` +} + +type ListCashAdvancesRequest struct { + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Search string `json:"search,omitempty"` + Status string `json:"status,omitempty" validate:"omitempty,oneof=draft approved rejected cancelled"` + // SettlementStatus filters on how much of the cash has been accounted for, + // which is derived from the spending charged to the advance rather than stored. + SettlementStatus string `json:"settlement_status,omitempty" validate:"omitempty,oneof=open partial settled"` + // Team is the single-value form of the two filters below, so the team picker can + // send back what it was given: a parent category id or "central" for Pusat. + Team string `json:"team,omitempty"` + TeamScope string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + StartDate *time.Time `json:"start_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` +} + +type ListCashAdvancesResponse struct { + CashAdvances []CashAdvanceResponse `json:"cash_advances"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go index 348cbd3..9f2ae25 100644 --- a/internal/contract/expense_contract.go +++ b/internal/contract/expense_contract.go @@ -7,15 +7,18 @@ import ( ) type CreateExpenseRequest struct { - Receiver string `json:"receiver" validate:"required"` - TransactionDate string `json:"transaction_date" validate:"required"` - CodeNumber string `json:"code_number" validate:"required"` - OutletID string `json:"outlet_id" validate:"required"` - Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"` - Description *string `json:"description,omitempty"` - Tax float64 `json:"tax"` - Total float64 `json:"total" validate:"required"` - Items []CreateExpenseItemRequest `json:"items" validate:"required"` + Receiver string `json:"receiver" validate:"required"` + TransactionDate string `json:"transaction_date" validate:"required"` + CodeNumber string `json:"code_number" validate:"required"` + OutletID string `json:"outlet_id" validate:"required"` + Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"` + Description *string `json:"description,omitempty"` + Tax float64 `json:"tax"` + Total float64 `json:"total" validate:"required"` + // CashAdvanceID marks the expense as paid out of cash advanced to a team, which is + // what accounts for that advance. + CashAdvanceID *string `json:"cash_advance_id,omitempty"` + Items []CreateExpenseItemRequest `json:"items" validate:"required"` } type CreateExpenseItemRequest struct { @@ -27,16 +30,18 @@ type CreateExpenseItemRequest struct { } type UpdateExpenseRequest struct { - Receiver *string `json:"receiver,omitempty"` - TransactionDate *string `json:"transaction_date,omitempty"` - CodeNumber *string `json:"code_number,omitempty"` - OutletID *string `json:"outlet_id,omitempty"` - Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"` - Description *string `json:"description,omitempty"` - Tax *float64 `json:"tax,omitempty"` - Total *float64 `json:"total,omitempty"` - Reserved1 *string `json:"reserved1,omitempty"` - Items []UpdateExpenseItemRequest `json:"items,omitempty"` + Receiver *string `json:"receiver,omitempty"` + TransactionDate *string `json:"transaction_date,omitempty"` + CodeNumber *string `json:"code_number,omitempty"` + OutletID *string `json:"outlet_id,omitempty"` + Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"` + Description *string `json:"description,omitempty"` + Tax *float64 `json:"tax,omitempty"` + Total *float64 `json:"total,omitempty"` + Reserved1 *string `json:"reserved1,omitempty"` + // An empty string unlinks the cash advance; omitting the field leaves it untouched. + CashAdvanceID *string `json:"cash_advance_id,omitempty"` + Items []UpdateExpenseItemRequest `json:"items,omitempty"` } type UpdateExpenseItemRequest struct { @@ -59,6 +64,7 @@ type ExpenseResponse struct { Tax float64 `json:"tax"` Total float64 `json:"total"` Reserved1 *string `json:"reserved1,omitempty"` + CashAdvanceID *uuid.UUID `json:"cash_advance_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Items []ExpenseItemResponse `json:"items,omitempty"` diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 26bbd52..a9bd480 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -7,15 +7,18 @@ import ( ) type CreatePurchaseOrderRequest struct { - VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"` - PONumber string `json:"po_number" validate:"required,min=1,max=50"` - TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD - DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD - Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"` - Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` - Message *string `json:"message,omitempty" validate:"omitempty"` - TeamScope *string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"` - TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"` + VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"` + PONumber string `json:"po_number" validate:"required,min=1,max=50"` + TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD + DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD + Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"` + Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` + Message *string `json:"message,omitempty" validate:"omitempty"` + TeamScope *string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"` + // CashAdvanceID marks the purchase as paid out of cash advanced to the team. Sending + // it without a team charges the purchase to the cash advance's team. + CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty" validate:"omitempty"` Items []CreatePurchaseOrderItemRequest `json:"items" validate:"required,min=1,dive"` AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"` } @@ -38,8 +41,10 @@ type UpdatePurchaseOrderRequest struct { Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` Message *string `json:"message,omitempty" validate:"omitempty"` // An empty string clears the team; omitting the field leaves it untouched. - TeamScope *string `json:"team_scope,omitempty" validate:"omitempty"` - TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"` + TeamScope *string `json:"team_scope,omitempty" validate:"omitempty"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"` + // An all-zero uuid unlinks the cash advance; omitting the field leaves it untouched. + CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty" validate:"omitempty"` Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty" validate:"omitempty,dive"` AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"` } @@ -68,6 +73,7 @@ type PurchaseOrderResponse struct { TotalAmount float64 `json:"total_amount"` TeamScope *string `json:"team_scope"` TeamCategoryID *uuid.UUID `json:"team_category_id"` + CashAdvanceID *uuid.UUID `json:"cash_advance_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Team *PurchaseTeamResponse `json:"team,omitempty"` diff --git a/internal/entities/cash_advance.go b/internal/entities/cash_advance.go new file mode 100644 index 0000000..fc9f66a --- /dev/null +++ b/internal/entities/cash_advance.go @@ -0,0 +1,63 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + + "gorm.io/gorm" +) + +// CashAdvance is money handed to a team up front so it can go shopping — kasbon in +// the Indonesian UI. While the cash is out it is still the outlet's, not a cost, so +// nothing about what the team bought lives here: that is read back from the purchase +// orders and expenses charged to the advance. +type CashAdvance struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` + OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"` + CodeNumber string `gorm:"not null;size:50" json:"code_number"` + // An advance is always handed to a team, so unlike a purchase order it has no + // "not chosen yet" state. TeamCategoryID is set only when the scope is category. + TeamScope string `gorm:"not null;size:20;index" json:"team_scope"` + TeamCategoryID *uuid.UUID `gorm:"type:uuid;index" json:"team_category_id"` + Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"` + ReturnedAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"returned_amount"` + IssuedDate time.Time `gorm:"type:date;not null" json:"issued_date"` + DueDate *time.Time `gorm:"type:date" json:"due_date"` + Status string `gorm:"not null;size:20;default:'draft'" json:"status"` + Description *string `gorm:"type:text" json:"description"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + // SettledAmount is filled in by the read queries from the spending charged to + // this advance. It has no column of its own, so it can never drift out of step + // with the purchases behind it; the arrow tag keeps writes from touching it. + SettledAmount float64 `gorm:"->;-:migration" json:"settled_amount"` + + Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` + Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` + TeamCategory *Category `gorm:"foreignKey:TeamCategoryID" json:"team_category,omitempty"` +} + +func (k *CashAdvance) BeforeCreate(tx *gorm.DB) error { + if k.ID == uuid.Nil { + k.ID = uuid.New() + } + return nil +} + +func (CashAdvance) TableName() string { + return "cash_advances" +} + +// CashAdvanceSettlement is one piece of spending charged to an advance. It is read +// out of purchase_orders and expenses, so it has no table of its own. +type CashAdvanceSettlement struct { + Type string `json:"type"` + ID uuid.UUID `json:"id"` + Number string `json:"number"` + Date time.Time `json:"date"` + Amount float64 `json:"amount"` + Status string `json:"status"` +} diff --git a/internal/entities/entities.go b/internal/entities/entities.go index c95a5c6..a5d6973 100644 --- a/internal/entities/entities.go +++ b/internal/entities/entities.go @@ -43,6 +43,7 @@ func GetAllEntities() []interface{} { &NotificationDelivery{}, &ProductOutletPrice{}, &Expense{}, + &CashAdvance{}, } } diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 9f391fe..0cdb08d 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -20,11 +20,14 @@ type Expense struct { Tax float64 `gorm:"type:decimal(15,2);not null;default:0" json:"tax"` Total float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total"` Reserved1 *string `gorm:"type:text" json:"reserved1"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + // CashAdvanceID is set when the expense was paid out of cash advanced to a team. + CashAdvanceID *uuid.UUID `gorm:"type:uuid;index" json:"cash_advance_id"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` + CashAdvance *CashAdvance `gorm:"foreignKey:CashAdvanceID" json:"cash_advance,omitempty"` Items []ExpenseItem `gorm:"foreignKey:ExpenseID" json:"items,omitempty"` } diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index e6f5208..251ab63 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -24,13 +24,17 @@ type PurchaseOrder struct { // 'central' for Pusat. Nil means no team was chosen, which is not the same as Pusat. TeamScope *string `gorm:"size:20;index" json:"team_scope" validate:"omitempty,oneof=category central"` TeamCategoryID *uuid.UUID `gorm:"type:uuid;index" json:"team_category_id" validate:"omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + // CashAdvanceID is set when the purchase was paid out of cash advanced to the team. + // It is what accounts for that advance, so the cash advance holds no copy of the items. + CashAdvanceID *uuid.UUID `gorm:"type:uuid;index" json:"cash_advance_id" validate:"omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"` TeamCategory *Category `gorm:"foreignKey:TeamCategoryID" json:"team_category,omitempty"` + CashAdvance *CashAdvance `gorm:"foreignKey:CashAdvanceID" json:"cash_advance,omitempty"` Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"` Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"` } diff --git a/internal/handler/cash_advance_handler.go b/internal/handler/cash_advance_handler.go new file mode 100644 index 0000000..52b0a9a --- /dev/null +++ b/internal/handler/cash_advance_handler.go @@ -0,0 +1,232 @@ +package handler + +import ( + "strconv" + "time" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" + "apskel-pos-be/internal/validator" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type CashAdvanceHandler struct { + cashAdvanceService service.CashAdvanceService + cashAdvanceValidator validator.CashAdvanceValidator +} + +func NewCashAdvanceHandler(cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator) *CashAdvanceHandler { + return &CashAdvanceHandler{ + cashAdvanceService: cashAdvanceService, + cashAdvanceValidator: cashAdvanceValidator, + } +} + +func (h *CashAdvanceHandler) CreateCashAdvance(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.CreateCashAdvanceRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::CreateCashAdvance -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::CreateCashAdvance") + return + } + + validationError, validationErrorCode := h.cashAdvanceValidator.ValidateCreateCashAdvanceRequest(&req) + if validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::CreateCashAdvance") + return + } + + response := h.cashAdvanceService.CreateCashAdvance(ctx, contextInfo, &req) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::CreateCashAdvance -> Failed to create cash advance from service") + } + + util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::CreateCashAdvance") +} + +func (h *CashAdvanceHandler) UpdateCashAdvance(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + cashAdvanceID, err := uuid.Parse(c.Param("id")) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvance -> Invalid cash advance ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance") + return + } + + var req contract.UpdateCashAdvanceRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvance -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance") + return + } + + validationError, validationErrorCode := h.cashAdvanceValidator.ValidateUpdateCashAdvanceRequest(&req) + if validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance") + return + } + + response := h.cashAdvanceService.UpdateCashAdvance(ctx, contextInfo, cashAdvanceID, &req) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::UpdateCashAdvance -> Failed to update cash advance from service") + } + + util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::UpdateCashAdvance") +} + +func (h *CashAdvanceHandler) DeleteCashAdvance(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + cashAdvanceID, err := uuid.Parse(c.Param("id")) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::DeleteCashAdvance -> Invalid cash advance ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::DeleteCashAdvance") + return + } + + response := h.cashAdvanceService.DeleteCashAdvance(ctx, contextInfo, cashAdvanceID) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::DeleteCashAdvance -> Failed to delete cash advance from service") + } + + util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::DeleteCashAdvance") +} + +func (h *CashAdvanceHandler) GetCashAdvance(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + cashAdvanceID, err := uuid.Parse(c.Param("id")) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::GetCashAdvance -> Invalid cash advance ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::GetCashAdvance") + return + } + + response := h.cashAdvanceService.GetCashAdvanceByID(ctx, contextInfo, cashAdvanceID) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::GetCashAdvance -> Failed to get cash advance from service") + } + + util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::GetCashAdvance") +} + +func (h *CashAdvanceHandler) ListCashAdvances(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + req := &contract.ListCashAdvancesRequest{ + Page: 1, + Limit: 10, + } + + if pageStr := c.Query("page"); pageStr != "" { + if page, err := strconv.Atoi(pageStr); err == nil { + req.Page = page + } + } + + if limitStr := c.Query("limit"); limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil { + req.Limit = limit + } + } + + req.Search = c.Query("search") + req.Status = c.Query("status") + req.SettlementStatus = c.Query("settlement_status") + req.Team = c.Query("team") + req.TeamScope = c.Query("team_scope") + + if teamCategoryIDStr := c.Query("team_category_id"); teamCategoryIDStr != "" { + if teamCategoryID, err := uuid.Parse(teamCategoryIDStr); err == nil { + req.TeamCategoryID = &teamCategoryID + } + } + + if startDateStr := c.Query("start_date"); startDateStr != "" { + if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil { + req.StartDate = &startDate + } + } + + if endDateStr := c.Query("end_date"); endDateStr != "" { + if endDate, err := time.Parse("2006-01-02", endDateStr); err == nil { + req.EndDate = &endDate + } + } + + validationError, validationErrorCode := h.cashAdvanceValidator.ValidateListCashAdvancesRequest(req) + if validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::ListCashAdvances") + return + } + + response := h.cashAdvanceService.ListCashAdvances(ctx, contextInfo, req) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::ListCashAdvances -> Failed to list cash advances from service") + } + + util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::ListCashAdvances") +} + +func (h *CashAdvanceHandler) UpdateCashAdvanceStatus(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + cashAdvanceID, err := uuid.Parse(c.Param("id")) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvanceStatus -> Invalid cash advance ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvanceStatus") + return + } + + status := c.Param("status") + if status == "" { + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Status parameter is required") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvanceStatus") + return + } + + response := h.cashAdvanceService.UpdateCashAdvanceStatus(ctx, contextInfo, cashAdvanceID, status) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::UpdateCashAdvanceStatus -> Failed to update cash advance status from service") + } + + util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::UpdateCashAdvanceStatus") +} + +// ListCashAdvanceTeams serves the team picker for the advance form: the parent +// categories of the caller's outlet, plus Pusat. Same list the purchase form uses. +func (h *CashAdvanceHandler) ListCashAdvanceTeams(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + response := h.cashAdvanceService.ListCashAdvanceTeams(ctx, contextInfo) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::ListCashAdvanceTeams -> Failed to list cash advance teams from service") + } + + util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::ListCashAdvanceTeams") +} diff --git a/internal/mappers/cash_advance_mapper.go b/internal/mappers/cash_advance_mapper.go new file mode 100644 index 0000000..9b5a94c --- /dev/null +++ b/internal/mappers/cash_advance_mapper.go @@ -0,0 +1,128 @@ +package mappers + +import ( + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +// Amounts are money rounded to two decimals, so anything under half a cent apart +// is the same figure. Comparing them directly would leave an advance a hundredth of +// a rupiah short of settled. +const cashAdvanceAmountEpsilon = 0.005 + +// cashAdvanceTeamFromEntity renders the team an advance was handed to. Unlike a +// purchase order there is always one, so this never returns nil. The category name +// is only filled in when TeamCategory was preloaded. +func cashAdvanceTeamFromEntity(entity *entities.CashAdvance) *models.PurchaseTeam { + team := &models.PurchaseTeam{Scope: entity.TeamScope} + + switch entity.TeamScope { + case constants.PurchaseTeamScopeCentral: + team.Name = constants.PurchaseTeamCentralName + case constants.PurchaseTeamScopeCategory: + team.CategoryID = entity.TeamCategoryID + if entity.TeamCategory != nil { + team.Name = entity.TeamCategory.Name + } + } + + return team +} + +// cashAdvanceSettlementStatus reads the money rather than any stored flag: cash is +// accounted for by spending charged to the advance plus what was handed back. A +// team that overspent still counts as settled — the excess is owed back to them. +func cashAdvanceSettlementStatus(amount, settled, returned float64) string { + accounted := settled + returned + + switch { + case accounted <= cashAdvanceAmountEpsilon: + return constants.CashAdvanceSettlementOpen + case amount-accounted > cashAdvanceAmountEpsilon: + return constants.CashAdvanceSettlementPartial + default: + return constants.CashAdvanceSettlementSettled + } +} + +func CashAdvanceEntityToModel(entity *entities.CashAdvance) *models.CashAdvance { + if entity == nil { + return nil + } + + return &models.CashAdvance{ + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + CodeNumber: entity.CodeNumber, + TeamScope: entity.TeamScope, + TeamCategoryID: entity.TeamCategoryID, + Amount: entity.Amount, + ReturnedAmount: entity.ReturnedAmount, + IssuedDate: entity.IssuedDate, + DueDate: entity.DueDate, + Status: entity.Status, + Description: entity.Description, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } +} + +func CashAdvanceEntityToResponse(entity *entities.CashAdvance) *models.CashAdvanceResponse { + if entity == nil { + return nil + } + + return &models.CashAdvanceResponse{ + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + CodeNumber: entity.CodeNumber, + TeamScope: entity.TeamScope, + TeamCategoryID: entity.TeamCategoryID, + Amount: entity.Amount, + SettledAmount: entity.SettledAmount, + ReturnedAmount: entity.ReturnedAmount, + RemainingAmount: entity.Amount - entity.SettledAmount - entity.ReturnedAmount, + SettlementStatus: cashAdvanceSettlementStatus(entity.Amount, entity.SettledAmount, entity.ReturnedAmount), + IssuedDate: entity.IssuedDate, + DueDate: entity.DueDate, + Status: entity.Status, + Description: entity.Description, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + Team: cashAdvanceTeamFromEntity(entity), + } +} + +func CashAdvanceEntitiesToResponses(entities []*entities.CashAdvance) []*models.CashAdvanceResponse { + if entities == nil { + return nil + } + + responses := make([]*models.CashAdvanceResponse, len(entities)) + for i, entity := range entities { + responses[i] = CashAdvanceEntityToResponse(entity) + } + return responses +} + +func CashAdvanceSettlementEntitiesToModels(settlements []*entities.CashAdvanceSettlement) []models.CashAdvanceSettlement { + if settlements == nil { + return nil + } + + result := make([]models.CashAdvanceSettlement, len(settlements)) + for i, settlement := range settlements { + result[i] = models.CashAdvanceSettlement{ + Type: settlement.Type, + ID: settlement.ID, + Number: settlement.Number, + Date: settlement.Date, + Amount: settlement.Amount, + Status: settlement.Status, + } + } + return result +} diff --git a/internal/mappers/cash_advance_mapper_test.go b/internal/mappers/cash_advance_mapper_test.go new file mode 100644 index 0000000..801f06b --- /dev/null +++ b/internal/mappers/cash_advance_mapper_test.go @@ -0,0 +1,101 @@ +package mappers + +import ( + "testing" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestCashAdvanceSettlementStatusFollowsTheMoney(t *testing.T) { + tests := []struct { + name string + amount float64 + settled float64 + returned float64 + expectedStatus string + expectedRemaining float64 + }{ + { + name: "nothing spent or returned is still open", + amount: 500000, + expectedStatus: constants.CashAdvanceSettlementOpen, + expectedRemaining: 500000, + }, + { + name: "some spending leaves it partial", + amount: 500000, + settled: 200000, + expectedStatus: constants.CashAdvanceSettlementPartial, + expectedRemaining: 300000, + }, + { + name: "cash handed back counts the same as spending", + amount: 500000, + settled: 300000, + returned: 100000, + expectedStatus: constants.CashAdvanceSettlementPartial, + expectedRemaining: 100000, + }, + { + name: "spending plus cash back covering the advance settles it", + amount: 500000, + settled: 420000, + returned: 80000, + expectedStatus: constants.CashAdvanceSettlementSettled, + expectedRemaining: 0, + }, + { + // The team paid the difference out of pocket, so the outlet owes them. + name: "overspending settles the cash advance and goes negative", + amount: 500000, + settled: 620000, + expectedStatus: constants.CashAdvanceSettlementSettled, + expectedRemaining: -120000, + }, + { + // Two decimals of money should not leave a cash advance a fraction short. + name: "a rounding crumb short still settles", + amount: 100000, + settled: 99999.999, + expectedStatus: constants.CashAdvanceSettlementSettled, + expectedRemaining: 0.001, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + response := CashAdvanceEntityToResponse(&entities.CashAdvance{ + ID: uuid.New(), + TeamScope: constants.PurchaseTeamScopeCentral, + Amount: tt.amount, + SettledAmount: tt.settled, + ReturnedAmount: tt.returned, + }) + + require.Equal(t, tt.expectedStatus, response.SettlementStatus) + require.InDelta(t, tt.expectedRemaining, response.RemainingAmount, 0.0001) + }) + } +} + +func TestCashAdvanceTeamAlwaysRendered(t *testing.T) { + categoryID := uuid.New() + + central := CashAdvanceEntityToResponse(&entities.CashAdvance{TeamScope: constants.PurchaseTeamScopeCentral}) + require.NotNil(t, central.Team) + require.Equal(t, constants.PurchaseTeamCentralName, central.Team.Name) + require.Nil(t, central.Team.CategoryID) + + category := CashAdvanceEntityToResponse(&entities.CashAdvance{ + TeamScope: constants.PurchaseTeamScopeCategory, + TeamCategoryID: &categoryID, + TeamCategory: &entities.Category{ID: categoryID, Name: "Dapur"}, + }) + require.NotNil(t, category.Team) + require.Equal(t, "Dapur", category.Team.Name) + require.Equal(t, &categoryID, category.Team.CategoryID) +} diff --git a/internal/mappers/expense_mapper.go b/internal/mappers/expense_mapper.go index c3b1e43..4d5f06a 100644 --- a/internal/mappers/expense_mapper.go +++ b/internal/mappers/expense_mapper.go @@ -66,6 +66,7 @@ func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse { Tax: entity.Tax, Total: entity.Total, Reserved1: entity.Reserved1, + CashAdvanceID: entity.CashAdvanceID, CreatedAt: entity.CreatedAt, UpdatedAt: entity.UpdatedAt, } diff --git a/internal/mappers/purchase_order_mapper.go b/internal/mappers/purchase_order_mapper.go index dddaa11..60a9df8 100644 --- a/internal/mappers/purchase_order_mapper.go +++ b/internal/mappers/purchase_order_mapper.go @@ -47,6 +47,7 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase TotalAmount: entity.TotalAmount, TeamScope: entity.TeamScope, TeamCategoryID: entity.TeamCategoryID, + CashAdvanceID: entity.CashAdvanceID, CreatedAt: entity.CreatedAt, UpdatedAt: entity.UpdatedAt, } @@ -71,6 +72,7 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO TotalAmount: model.TotalAmount, TeamScope: model.TeamScope, TeamCategoryID: model.TeamCategoryID, + CashAdvanceID: model.CashAdvanceID, CreatedAt: model.CreatedAt, UpdatedAt: model.UpdatedAt, } @@ -95,6 +97,7 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch TotalAmount: entity.TotalAmount, TeamScope: entity.TeamScope, TeamCategoryID: entity.TeamCategoryID, + CashAdvanceID: entity.CashAdvanceID, CreatedAt: entity.CreatedAt, UpdatedAt: entity.UpdatedAt, Team: purchaseTeamFromEntity(entity), diff --git a/internal/models/cash_advance.go b/internal/models/cash_advance.go new file mode 100644 index 0000000..462d952 --- /dev/null +++ b/internal/models/cash_advance.go @@ -0,0 +1,104 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type CashAdvance struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID uuid.UUID `json:"outlet_id"` + CodeNumber string `json:"code_number"` + TeamScope string `json:"team_scope"` + TeamCategoryID *uuid.UUID `json:"team_category_id"` + Amount float64 `json:"amount"` + ReturnedAmount float64 `json:"returned_amount"` + IssuedDate time.Time `json:"issued_date"` + DueDate *time.Time `json:"due_date"` + Status string `json:"status"` + Description *string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CashAdvanceResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID uuid.UUID `json:"outlet_id"` + CodeNumber string `json:"code_number"` + TeamScope string `json:"team_scope"` + TeamCategoryID *uuid.UUID `json:"team_category_id"` + Amount float64 `json:"amount"` + // SettledAmount is the spending charged to this advance, ReturnedAmount the cash + // handed back, and RemainingAmount what is still out with the team. A negative + // remaining amount means the team overspent and is owed the difference. + SettledAmount float64 `json:"settled_amount"` + ReturnedAmount float64 `json:"returned_amount"` + RemainingAmount float64 `json:"remaining_amount"` + SettlementStatus string `json:"settlement_status"` + IssuedDate time.Time `json:"issued_date"` + DueDate *time.Time `json:"due_date"` + Status string `json:"status"` + Description *string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Team *PurchaseTeam `json:"team,omitempty"` + Settlements []CashAdvanceSettlement `json:"settlements,omitempty"` +} + +// CashAdvanceSettlement is one purchase order or expense paid out of the advance. +type CashAdvanceSettlement struct { + Type string `json:"type"` + ID uuid.UUID `json:"id"` + Number string `json:"number"` + Date time.Time `json:"date"` + Amount float64 `json:"amount"` + Status string `json:"status"` +} + +type CreateCashAdvanceRequest struct { + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + CodeNumber string `json:"code_number"` + TeamScope string `json:"team_scope"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + Amount float64 `json:"amount"` + IssuedDate time.Time `json:"issued_date"` + DueDate *time.Time `json:"due_date,omitempty"` + Status *string `json:"status,omitempty"` + Description *string `json:"description,omitempty"` +} + +type UpdateCashAdvanceRequest struct { + CodeNumber *string `json:"code_number,omitempty"` + TeamScope *string `json:"team_scope,omitempty"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + Amount *float64 `json:"amount,omitempty"` + ReturnedAmount *float64 `json:"returned_amount,omitempty"` + IssuedDate *time.Time `json:"issued_date,omitempty"` + DueDate *time.Time `json:"due_date,omitempty"` + Status *string `json:"status,omitempty"` + Description *string `json:"description,omitempty"` +} + +type ListCashAdvancesRequest struct { + Page int `json:"page"` + Limit int `json:"limit"` + Search string `json:"search,omitempty"` + Status string `json:"status,omitempty"` + SettlementStatus string `json:"settlement_status,omitempty"` + Team string `json:"team,omitempty"` + TeamScope string `json:"team_scope,omitempty"` + TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + StartDate *time.Time `json:"start_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` +} + +type ListCashAdvancesResponse struct { + CashAdvances []CashAdvanceResponse `json:"cash_advances"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} diff --git a/internal/models/expense.go b/internal/models/expense.go index 859ed69..0ce88c6 100644 --- a/internal/models/expense.go +++ b/internal/models/expense.go @@ -46,6 +46,7 @@ type ExpenseResponse struct { Tax float64 `json:"tax"` Total float64 `json:"total"` Reserved1 *string `json:"reserved1"` + CashAdvanceID *uuid.UUID `json:"cash_advance_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Items []ExpenseItemResponse `json:"items,omitempty"` @@ -76,6 +77,7 @@ type CreateExpenseRequest struct { Description *string `json:"description"` Tax float64 `json:"tax"` Total float64 `json:"total"` + CashAdvanceID *string `json:"cash_advance_id,omitempty"` Items []CreateExpenseItemRequest `json:"items"` } @@ -97,6 +99,7 @@ type UpdateExpenseRequest struct { Tax *float64 `json:"tax,omitempty"` Total *float64 `json:"total,omitempty"` Reserved1 *string `json:"reserved1,omitempty"` + CashAdvanceID *string `json:"cash_advance_id,omitempty"` Items []UpdateExpenseItemRequest `json:"items,omitempty"` } diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index 6ac202e..4836f34 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -20,6 +20,7 @@ type PurchaseOrder struct { TotalAmount float64 `json:"total_amount"` TeamScope *string `json:"team_scope"` TeamCategoryID *uuid.UUID `json:"team_category_id"` + CashAdvanceID *uuid.UUID `json:"cash_advance_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -66,6 +67,7 @@ type PurchaseOrderResponse struct { TotalAmount float64 `json:"total_amount"` TeamScope *string `json:"team_scope"` TeamCategoryID *uuid.UUID `json:"team_category_id"` + CashAdvanceID *uuid.UUID `json:"cash_advance_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Team *PurchaseTeam `json:"team,omitempty"` @@ -109,6 +111,7 @@ type CreatePurchaseOrderRequest struct { Message *string `json:"message,omitempty"` TeamScope *string `json:"team_scope,omitempty"` TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty"` Items []CreatePurchaseOrderItemRequest `json:"items"` AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"` } @@ -132,6 +135,7 @@ type UpdatePurchaseOrderRequest struct { Message *string `json:"message,omitempty"` TeamScope *string `json:"team_scope,omitempty"` TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"` + CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty"` Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty"` AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"` } diff --git a/internal/processor/cash_advance_processor.go b/internal/processor/cash_advance_processor.go new file mode 100644 index 0000000..058d279 --- /dev/null +++ b/internal/processor/cash_advance_processor.go @@ -0,0 +1,286 @@ +package processor + +import ( + "context" + "fmt" + "strings" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/mappers" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" +) + +type CashAdvanceProcessor interface { + CreateCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreateCashAdvanceRequest) (*models.CashAdvanceResponse, error) + UpdateCashAdvance(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateCashAdvanceRequest) (*models.CashAdvanceResponse, error) + DeleteCashAdvance(ctx context.Context, id, organizationID uuid.UUID) error + GetCashAdvanceByID(ctx context.Context, id, organizationID uuid.UUID) (*models.CashAdvanceResponse, error) + ListCashAdvances(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.CashAdvanceResponse, int, error) + UpdateCashAdvanceStatus(ctx context.Context, id, organizationID uuid.UUID, status string) (*models.CashAdvanceResponse, error) + ListCashAdvanceTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) +} + +type CashAdvanceProcessorImpl struct { + cashAdvanceRepo CashAdvanceRepository + categoryRepo CategoryRepository +} + +func NewCashAdvanceProcessorImpl(cashAdvanceRepo CashAdvanceRepository, categoryRepo CategoryRepository) *CashAdvanceProcessorImpl { + return &CashAdvanceProcessorImpl{ + cashAdvanceRepo: cashAdvanceRepo, + categoryRepo: categoryRepo, + } +} + +func (p *CashAdvanceProcessorImpl) CreateCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreateCashAdvanceRequest) (*models.CashAdvanceResponse, error) { + // The cash leaves one drawer, so the outlet has to be known: either the caller + // named it or it comes from the outlet they are signed in to. + resolvedOutletID := req.OutletID + if resolvedOutletID == nil { + resolvedOutletID = outletID + } + if resolvedOutletID == nil || *resolvedOutletID == uuid.Nil { + return nil, fmt.Errorf("outlet_id is required") + } + + teamScope, teamCategoryID, err := p.resolveCashAdvanceTeam(ctx, organizationID, resolvedOutletID, &req.TeamScope, req.TeamCategoryID) + if err != nil { + return nil, err + } + + existing, err := p.cashAdvanceRepo.GetByCodeNumber(ctx, req.CodeNumber, organizationID) + if err == nil && existing != nil { + return nil, fmt.Errorf("cash advance with code number %s already exists in this organization", req.CodeNumber) + } + + status := constants.CashAdvanceStatusDraft + if req.Status != nil { + status = *req.Status + } + + cashAdvance := &entities.CashAdvance{ + OrganizationID: organizationID, + OutletID: *resolvedOutletID, + CodeNumber: req.CodeNumber, + TeamScope: teamScope, + TeamCategoryID: teamCategoryID, + Amount: req.Amount, + IssuedDate: req.IssuedDate, + DueDate: req.DueDate, + Status: status, + Description: req.Description, + } + + if err := p.cashAdvanceRepo.Create(ctx, cashAdvance); err != nil { + return nil, fmt.Errorf("failed to create cash advance: %w", err) + } + + created, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID) + if err != nil { + return nil, fmt.Errorf("failed to get created cash advance: %w", err) + } + + return mappers.CashAdvanceEntityToResponse(created), nil +} + +func (p *CashAdvanceProcessorImpl) UpdateCashAdvance(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateCashAdvanceRequest) (*models.CashAdvanceResponse, error) { + cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("cash advance not found: %w", err) + } + + if req.CodeNumber != nil && *req.CodeNumber != cashAdvance.CodeNumber { + existing, err := p.cashAdvanceRepo.GetByCodeNumber(ctx, *req.CodeNumber, organizationID) + if err == nil && existing != nil { + return nil, fmt.Errorf("cash advance with code number %s already exists in this organization", *req.CodeNumber) + } + cashAdvance.CodeNumber = *req.CodeNumber + } + + if req.TeamScope != nil { + teamScope, teamCategoryID, err := p.resolveCashAdvanceTeam(ctx, organizationID, &cashAdvance.OutletID, req.TeamScope, req.TeamCategoryID) + if err != nil { + return nil, err + } + cashAdvance.TeamScope = teamScope + cashAdvance.TeamCategoryID = teamCategoryID + } + + if req.Amount != nil { + cashAdvance.Amount = *req.Amount + } + if req.ReturnedAmount != nil { + cashAdvance.ReturnedAmount = *req.ReturnedAmount + } + if req.IssuedDate != nil { + cashAdvance.IssuedDate = *req.IssuedDate + } + if req.DueDate != nil { + cashAdvance.DueDate = req.DueDate + } + if req.Status != nil { + if err := p.guardStatusChange(ctx, cashAdvance, *req.Status); err != nil { + return nil, err + } + cashAdvance.Status = *req.Status + } + if req.Description != nil { + cashAdvance.Description = req.Description + } + + // Cash handed back can only ever be part of the cash handed out. + if cashAdvance.ReturnedAmount > cashAdvance.Amount { + return nil, fmt.Errorf("returned_amount cannot be greater than the cash advance amount") + } + + if err := p.cashAdvanceRepo.Update(ctx, cashAdvance); err != nil { + return nil, fmt.Errorf("failed to update cash advance: %w", err) + } + + updated, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID) + if err != nil { + return nil, fmt.Errorf("failed to get updated cash advance: %w", err) + } + + return mappers.CashAdvanceEntityToResponse(updated), nil +} + +func (p *CashAdvanceProcessorImpl) DeleteCashAdvance(ctx context.Context, id, organizationID uuid.UUID) error { + if _, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID); err != nil { + return fmt.Errorf("cash advance not found: %w", err) + } + + // The foreign keys would refuse this anyway, but not in words anyone can act on. + count, err := p.cashAdvanceRepo.CountSettlements(ctx, id) + if err != nil { + return fmt.Errorf("failed to check cash advance settlements: %w", err) + } + if count > 0 { + return fmt.Errorf("cash advance cannot be deleted because %d purchase orders or expenses are charged to it", count) + } + + if err := p.cashAdvanceRepo.Delete(ctx, id); err != nil { + return fmt.Errorf("failed to delete cash advance: %w", err) + } + + return nil +} + +func (p *CashAdvanceProcessorImpl) GetCashAdvanceByID(ctx context.Context, id, organizationID uuid.UUID) (*models.CashAdvanceResponse, error) { + cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("cash advance not found: %w", err) + } + + response := mappers.CashAdvanceEntityToResponse(cashAdvance) + + // The detail view is where someone checks a cash advance off, so it carries the + // spending behind the settled figure. The list deliberately does not. + settlements, err := p.cashAdvanceRepo.ListSettlements(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to list cash advance settlements: %w", err) + } + response.Settlements = mappers.CashAdvanceSettlementEntitiesToModels(settlements) + + return response, nil +} + +func (p *CashAdvanceProcessorImpl) ListCashAdvances(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.CashAdvanceResponse, int, error) { + offset := (page - 1) * limit + cashAdvances, total, err := p.cashAdvanceRepo.List(ctx, organizationID, filters, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to list cash advances: %w", err) + } + + responses := mappers.CashAdvanceEntitiesToResponses(cashAdvances) + totalPages := int((total + int64(limit) - 1) / int64(limit)) + + return responses, totalPages, nil +} + +func (p *CashAdvanceProcessorImpl) UpdateCashAdvanceStatus(ctx context.Context, id, organizationID uuid.UUID, status string) (*models.CashAdvanceResponse, error) { + cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("cash advance not found: %w", err) + } + + if !constants.IsValidCashAdvanceStatus(status) { + return nil, fmt.Errorf("status must be one of: %s", strings.Join(constants.GetAllCashAdvanceStatuses(), ", ")) + } + + if err := p.guardStatusChange(ctx, cashAdvance, status); err != nil { + return nil, err + } + + cashAdvance.Status = status + if err := p.cashAdvanceRepo.Update(ctx, cashAdvance); err != nil { + return nil, fmt.Errorf("failed to update cash advance status: %w", err) + } + + updated, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID) + if err != nil { + return nil, fmt.Errorf("failed to get updated cash advance: %w", err) + } + + return mappers.CashAdvanceEntityToResponse(updated), nil +} + +func (p *CashAdvanceProcessorImpl) ListCashAdvanceTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) { + return listTeams(ctx, p.categoryRepo, organizationID, outletID) +} + +// guardStatusChange refuses to withdraw an advance that spending already points at. +// Rejecting or cancelling it would leave those purchases claiming to have been paid +// out of cash the books say never went out. +func (p *CashAdvanceProcessorImpl) guardStatusChange(ctx context.Context, cashAdvance *entities.CashAdvance, status string) error { + if status != constants.CashAdvanceStatusRejected && status != constants.CashAdvanceStatusCancelled { + return nil + } + + count, err := p.cashAdvanceRepo.CountSettlements(ctx, cashAdvance.ID) + if err != nil { + return fmt.Errorf("failed to check cash advance settlements: %w", err) + } + if count > 0 { + return fmt.Errorf("cash advance cannot be %s because %d purchase orders or expenses are charged to it", status, count) + } + + return nil +} + +// resolveCashAdvanceTeam is resolveTeamSelection with the one rule an advance adds: +// the cash is handed to a team, so there is no such thing as one without a team. +func (p *CashAdvanceProcessorImpl) resolveCashAdvanceTeam(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (string, *uuid.UUID, error) { + resolvedScope, resolvedCategoryID, err := resolveTeamSelection(ctx, p.categoryRepo, organizationID, outletID, scope, categoryID) + if err != nil { + return "", nil, err + } + if resolvedScope == nil { + return "", nil, fmt.Errorf("team_scope is required") + } + + return *resolvedScope, resolvedCategoryID, nil +} + +// resolveSpendingCashAdvance checks that a purchase order or expense may be charged +// to the advance it names: same organization and outlet, and the money actually +// approved to leave the drawer. Draft or cancelled advances cannot be spent against. +func resolveSpendingCashAdvance(ctx context.Context, cashAdvanceRepo CashAdvanceRepository, cashAdvanceID, organizationID uuid.UUID, outletID *uuid.UUID) (*entities.CashAdvance, error) { + cashAdvance, err := cashAdvanceRepo.GetByIDAndOrganizationID(ctx, cashAdvanceID, organizationID) + if err != nil { + return nil, fmt.Errorf("cash advance not found: %w", err) + } + + if cashAdvance.Status != constants.CashAdvanceStatusApproved { + return nil, fmt.Errorf("cash advance %s is %s, only an approved cash advance can be spent against", cashAdvance.CodeNumber, cashAdvance.Status) + } + + if outletID != nil && *outletID != uuid.Nil && cashAdvance.OutletID != *outletID { + return nil, fmt.Errorf("cash advance %s belongs to a different outlet", cashAdvance.CodeNumber) + } + + return cashAdvance, nil +} diff --git a/internal/processor/cash_advance_repository.go b/internal/processor/cash_advance_repository.go new file mode 100644 index 0000000..961bf58 --- /dev/null +++ b/internal/processor/cash_advance_repository.go @@ -0,0 +1,20 @@ +package processor + +import ( + "apskel-pos-be/internal/entities" + "context" + + "github.com/google/uuid" +) + +type CashAdvanceRepository interface { + Create(ctx context.Context, cashAdvance *entities.CashAdvance) error + GetByID(ctx context.Context, id uuid.UUID) (*entities.CashAdvance, error) + GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.CashAdvance, error) + GetByCodeNumber(ctx context.Context, codeNumber string, organizationID uuid.UUID) (*entities.CashAdvance, error) + Update(ctx context.Context, cashAdvance *entities.CashAdvance) error + Delete(ctx context.Context, id uuid.UUID) error + List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.CashAdvance, int64, error) + ListSettlements(ctx context.Context, cashAdvanceID uuid.UUID) ([]*entities.CashAdvanceSettlement, error) + CountSettlements(ctx context.Context, cashAdvanceID uuid.UUID) (int64, error) +} diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index 5419b1c..7a69991 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -3,6 +3,7 @@ package processor import ( "context" "fmt" + "strings" "time" "apskel-pos-be/internal/constants" @@ -25,12 +26,14 @@ type ExpenseProcessor interface { type ExpenseProcessorImpl struct { expenseRepo ExpenseRepository purchaseCategoryRepo PurchaseCategoryRepository + cashAdvanceRepo CashAdvanceRepository } -func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository) *ExpenseProcessorImpl { +func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository, cashAdvanceRepo CashAdvanceRepository) *ExpenseProcessorImpl { return &ExpenseProcessorImpl{ expenseRepo: expenseRepo, purchaseCategoryRepo: purchaseCategoryRepo, + cashAdvanceRepo: cashAdvanceRepo, } } @@ -50,6 +53,11 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID status = *req.Status } + cashAdvanceID, err := p.resolveExpenseCashAdvance(ctx, organizationID, outletID, req.CashAdvanceID) + if err != nil { + return nil, err + } + items := make([]entities.ExpenseItem, len(req.Items)) for i, itemReq := range req.Items { chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID) @@ -84,6 +92,7 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID Description: req.Description, Tax: req.Tax, Total: req.Total, + CashAdvanceID: cashAdvanceID, } err = p.expenseRepo.Create(ctx, expenseEntity) @@ -149,6 +158,14 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati if req.Reserved1 != nil { expenseEntity.Reserved1 = req.Reserved1 } + // An empty cash_advance_id unlinks the expense; omitting the field leaves it alone. + if req.CashAdvanceID != nil { + cashAdvanceID, err := p.resolveExpenseCashAdvance(ctx, organizationID, expenseEntity.OutletID, req.CashAdvanceID) + if err != nil { + return nil, err + } + expenseEntity.CashAdvanceID = cashAdvanceID + } var items []entities.ExpenseItem if req.Items != nil { @@ -334,6 +351,25 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod }, nil } +// resolveExpenseCashAdvance checks the expense may be charged to the cash advance it names. +// An empty value means no cash advance at all, which is how an update unlinks one. +func (p *ExpenseProcessorImpl) resolveExpenseCashAdvance(ctx context.Context, organizationID, outletID uuid.UUID, raw *string) (*uuid.UUID, error) { + if raw == nil || strings.TrimSpace(*raw) == "" { + return nil, nil + } + + cashAdvanceID, err := uuid.Parse(strings.TrimSpace(*raw)) + if err != nil { + return nil, fmt.Errorf("invalid cash_advance_id: %w", err) + } + + if _, err := resolveSpendingCashAdvance(ctx, p.cashAdvanceRepo, cashAdvanceID, organizationID, &outletID); err != nil { + return nil, err + } + + return &cashAdvanceID, nil +} + func (p *ExpenseProcessorImpl) validateExpensePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID) error { category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID) if err != nil { diff --git a/internal/processor/expense_processor_test.go b/internal/processor/expense_processor_test.go index ba0fb3e..b22eb25 100644 --- a/internal/processor/expense_processor_test.go +++ b/internal/processor/expense_processor_test.go @@ -99,10 +99,40 @@ func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uui return nil } +// Expenses in these tests are paid straight out of the drawer, so nothing here +// reaches the cash advance repository. +type expenseCashAdvanceRepositoryStub struct{} + +func (*expenseCashAdvanceRepositoryStub) Create(context.Context, *entities.CashAdvance) error { + return nil +} +func (*expenseCashAdvanceRepositoryStub) GetByID(context.Context, uuid.UUID) (*entities.CashAdvance, error) { + return nil, nil +} +func (*expenseCashAdvanceRepositoryStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.CashAdvance, error) { + return nil, nil +} +func (*expenseCashAdvanceRepositoryStub) GetByCodeNumber(context.Context, string, uuid.UUID) (*entities.CashAdvance, error) { + return nil, nil +} +func (*expenseCashAdvanceRepositoryStub) Update(context.Context, *entities.CashAdvance) error { + return nil +} +func (*expenseCashAdvanceRepositoryStub) Delete(context.Context, uuid.UUID) error { return nil } +func (*expenseCashAdvanceRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.CashAdvance, int64, error) { + return nil, 0, nil +} +func (*expenseCashAdvanceRepositoryStub) ListSettlements(context.Context, uuid.UUID) ([]*entities.CashAdvanceSettlement, error) { + return nil, nil +} +func (*expenseCashAdvanceRepositoryStub) CountSettlements(context.Context, uuid.UUID) (int64, error) { + return 0, nil +} + func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { repo := &expenseRepositoryCaptureStub{} purchaseCategoryID := uuid.New() - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{}) chartOfAccountID := uuid.New() resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ @@ -133,7 +163,7 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { repo := &expenseRepositoryCaptureStub{} purchaseCategoryID := uuid.New() - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{}) resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ Receiver: "Cashier", @@ -160,7 +190,7 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { repo := &expenseRepositoryCaptureStub{} purchaseCategoryID := uuid.New() - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{}) status := "approved" resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ @@ -189,7 +219,7 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T) { repo := &expenseRepositoryCaptureStub{} purchaseCategoryID := uuid.New() - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial), &expenseCashAdvanceRepositoryStub{}) resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ Receiver: "Cashier", @@ -266,7 +296,7 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te }, }, } - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{}) resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{ OrganizationID: uuid.New(), diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index 867f833..2b8d662 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -1,13 +1,11 @@ package processor import ( - "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" "context" "fmt" - "strings" "github.com/google/uuid" ) @@ -25,13 +23,14 @@ type PurchaseOrderProcessor interface { } type PurchaseOrderProcessorImpl struct { - purchaseOrderRepo PurchaseOrderRepository - vendorRepo VendorRepository - ingredientRepo IngredientRepository - purchaseCategoryRepo PurchaseCategoryRepository - categoryRepo CategoryRepository - unitRepo UnitRepository - fileRepo FileRepository + purchaseOrderRepo PurchaseOrderRepository + vendorRepo VendorRepository + ingredientRepo IngredientRepository + purchaseCategoryRepo PurchaseCategoryRepository + categoryRepo CategoryRepository + cashAdvanceRepo CashAdvanceRepository + unitRepo UnitRepository + fileRepo FileRepository // Kept wired but currently unused: purchase orders are a record of spending // only, so nothing here moves stock or converts units. These stay so that // tying purchases back to inventory is a change in one place. @@ -45,6 +44,7 @@ func NewPurchaseOrderProcessorImpl( ingredientRepo IngredientRepository, purchaseCategoryRepo PurchaseCategoryRepository, categoryRepo CategoryRepository, + cashAdvanceRepo CashAdvanceRepository, unitRepo UnitRepository, fileRepo FileRepository, inventoryMovementService InventoryMovementService, @@ -56,6 +56,7 @@ func NewPurchaseOrderProcessorImpl( ingredientRepo: ingredientRepo, purchaseCategoryRepo: purchaseCategoryRepo, categoryRepo: categoryRepo, + cashAdvanceRepo: cashAdvanceRepo, unitRepo: unitRepo, fileRepo: fileRepo, inventoryMovementService: inventoryMovementService, @@ -77,6 +78,11 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or return nil, err } + teamScope, teamCategoryID, err = p.applyCashAdvance(ctx, organizationID, outletID, req.CashAdvanceID, teamScope, teamCategoryID) + if err != nil { + return nil, err + } + // Check if PO number already exists in organization existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID) if err == nil && existingPO != nil { @@ -140,6 +146,7 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or TotalAmount: totalAmount, TeamScope: teamScope, TeamCategoryID: teamCategoryID, + CashAdvanceID: req.CashAdvanceID, } if req.Status != nil { @@ -247,6 +254,26 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id poEntity.TeamCategoryID = teamCategoryID } + // An all-zero cash advance id unlinks the purchase; omitting the field leaves it alone. + if req.CashAdvanceID != nil { + if *req.CashAdvanceID == uuid.Nil { + poEntity.CashAdvanceID = nil + } else { + poEntity.CashAdvanceID = req.CashAdvanceID + } + } + + // Recheck the pairing whenever either side moved: a purchase can end up on a + // cash advance belonging to another team otherwise. + if poEntity.CashAdvanceID != nil && (req.CashAdvanceID != nil || req.TeamScope != nil) { + teamScope, teamCategoryID, err := p.applyCashAdvance(ctx, organizationID, poEntity.OutletID, poEntity.CashAdvanceID, poEntity.TeamScope, poEntity.TeamCategoryID) + if err != nil { + return nil, err + } + poEntity.TeamScope = teamScope + poEntity.TeamCategoryID = teamCategoryID + } + // Update items if provided if req.Items != nil { totalAmount := 0.0 @@ -467,77 +494,49 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte return mappers.PurchaseOrderEntityToResponse(updatedPO), nil } -// ListPurchaseTeams returns the teams a purchase can be charged to: the parent -// categories of the outlet in scope, followed by Pusat. Pusat has no category row, -// so it is appended here rather than read from the database. +// ListPurchaseTeams returns the teams a purchase can be charged to. Cash advances are +// charged to the same teams, so the list itself is built in one shared place. func (p *PurchaseOrderProcessorImpl) ListPurchaseTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) { - categories, err := p.categoryRepo.ListParentCategories(ctx, organizationID, outletID) - if err != nil { - return nil, fmt.Errorf("failed to list parent categories: %w", err) - } - - teams := make([]models.PurchaseTeam, 0, len(categories)+1) - for _, category := range categories { - categoryID := category.ID - teams = append(teams, models.PurchaseTeam{ - Scope: constants.PurchaseTeamScopeCategory, - CategoryID: &categoryID, - Name: category.Name, - }) - } - - teams = append(teams, models.PurchaseTeam{ - Scope: constants.PurchaseTeamScopeCentral, - Name: constants.PurchaseTeamCentralName, - }) - - return &models.ListPurchaseTeamsResponse{Teams: teams}, nil + return listTeams(ctx, p.categoryRepo, organizationID, outletID) } // resolvePurchaseTeam turns a requested team into the scope/category pair stored on -// the purchase order, mirroring the database check constraint. A nil or empty scope -// leaves the purchase without a team, which is deliberately different from Pusat. -// Which outlet's Pusat a purchase belongs to comes from the purchase order's outlet, -// so 'central' needs nothing stored beyond the scope itself. +// the purchase order. A nil or empty scope leaves the purchase without a team, which +// is deliberately different from Pusat. func (p *PurchaseOrderProcessorImpl) resolvePurchaseTeam(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (*string, *uuid.UUID, error) { - if scope == nil { - return nil, nil, nil + return resolveTeamSelection(ctx, p.categoryRepo, organizationID, outletID, scope, categoryID) +} + +// applyCashAdvance checks a purchase may be charged to the cash advance it names, and returns +// the team it should carry. A purchase paid out of a team's cash belongs to that +// team, so an unassigned purchase inherits it and an assigned one has to agree. +func (p *PurchaseOrderProcessorImpl) applyCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID, cashAdvanceID *uuid.UUID, teamScope *string, teamCategoryID *uuid.UUID) (*string, *uuid.UUID, error) { + if cashAdvanceID == nil { + return teamScope, teamCategoryID, nil } - switch strings.TrimSpace(*scope) { - case "": - return nil, nil, nil - - case constants.PurchaseTeamScopeCentral: - resolved := constants.PurchaseTeamScopeCentral - return &resolved, nil, nil - - case constants.PurchaseTeamScopeCategory: - if categoryID == nil { - return nil, nil, fmt.Errorf("team_category_id is required when team_scope is category") - } - - category, err := p.categoryRepo.GetByID(ctx, *categoryID) - if err != nil { - return nil, nil, fmt.Errorf("team category not found: %w", err) - } - if category.OrganizationID != organizationID { - return nil, nil, fmt.Errorf("team category does not belong to this organization") - } - if category.ParentID != nil { - return nil, nil, fmt.Errorf("team must be a parent category") - } - // Categories without an outlet are shared, so only an outlet-specific - // category has to match the outlet the purchase is booked against. - if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID { - return nil, nil, fmt.Errorf("team category belongs to a different outlet") - } - - resolved := constants.PurchaseTeamScopeCategory - return &resolved, &category.ID, nil + cashAdvance, err := resolveSpendingCashAdvance(ctx, p.cashAdvanceRepo, *cashAdvanceID, organizationID, outletID) + if err != nil { + return nil, nil, err } - return nil, nil, fmt.Errorf("team_scope must be one of: category, central") + if teamScope == nil { + scope := cashAdvance.TeamScope + return &scope, cashAdvance.TeamCategoryID, nil + } + + if *teamScope != cashAdvance.TeamScope || !sameUUID(teamCategoryID, cashAdvance.TeamCategoryID) { + return nil, nil, fmt.Errorf("purchase order team must match the team cash advance %s was issued to", cashAdvance.CodeNumber) + } + + return teamScope, teamCategoryID, nil +} + +func sameUUID(a, b *uuid.UUID) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b } func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) { diff --git a/internal/processor/team.go b/internal/processor/team.go new file mode 100644 index 0000000..a38a533 --- /dev/null +++ b/internal/processor/team.go @@ -0,0 +1,89 @@ +package processor + +import ( + "context" + "fmt" + "strings" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" +) + +// Teams are the parent product categories, plus Pusat for spending that belongs to +// no single team. Both purchase orders and cash advances are charged to one, so the rules +// for picking and storing a team live here rather than in either processor. + +// listTeams returns the teams money can be charged to: the parent categories of the +// outlet in scope, followed by Pusat. Pusat has no category row, so it is appended +// here rather than read from the database. +func listTeams(ctx context.Context, categoryRepo CategoryRepository, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) { + categories, err := categoryRepo.ListParentCategories(ctx, organizationID, outletID) + if err != nil { + return nil, fmt.Errorf("failed to list parent categories: %w", err) + } + + teams := make([]models.PurchaseTeam, 0, len(categories)+1) + for _, category := range categories { + categoryID := category.ID + teams = append(teams, models.PurchaseTeam{ + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + Name: category.Name, + }) + } + + teams = append(teams, models.PurchaseTeam{ + Scope: constants.PurchaseTeamScopeCentral, + Name: constants.PurchaseTeamCentralName, + }) + + return &models.ListPurchaseTeamsResponse{Teams: teams}, nil +} + +// resolveTeamSelection turns a requested team into the scope/category pair that gets +// stored, mirroring the database check constraint. A nil or empty scope means no +// team, which is deliberately different from Pusat — callers that require a team +// reject that case before getting here. Which outlet's Pusat it is comes from the +// record's own outlet, so 'central' needs nothing stored beyond the scope itself. +func resolveTeamSelection(ctx context.Context, categoryRepo CategoryRepository, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (*string, *uuid.UUID, error) { + if scope == nil { + return nil, nil, nil + } + + switch strings.TrimSpace(*scope) { + case "": + return nil, nil, nil + + case constants.PurchaseTeamScopeCentral: + resolved := constants.PurchaseTeamScopeCentral + return &resolved, nil, nil + + case constants.PurchaseTeamScopeCategory: + if categoryID == nil { + return nil, nil, fmt.Errorf("team_category_id is required when team_scope is category") + } + + category, err := categoryRepo.GetByID(ctx, *categoryID) + if err != nil { + return nil, nil, fmt.Errorf("team category not found: %w", err) + } + if category.OrganizationID != organizationID { + return nil, nil, fmt.Errorf("team category does not belong to this organization") + } + if category.ParentID != nil { + return nil, nil, fmt.Errorf("team must be a parent category") + } + // Categories without an outlet are shared, so only an outlet-specific + // category has to match the outlet the record is booked against. + if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID { + return nil, nil, fmt.Errorf("team category belongs to a different outlet") + } + + resolved := constants.PurchaseTeamScopeCategory + return &resolved, &category.ID, nil + } + + return nil, nil, fmt.Errorf("team_scope must be one of: category, central") +} diff --git a/internal/repository/cash_advance_repository.go b/internal/repository/cash_advance_repository.go new file mode 100644 index 0000000..738955c --- /dev/null +++ b/internal/repository/cash_advance_repository.go @@ -0,0 +1,219 @@ +package repository + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// cashAdvanceSettledAmountExpr sums the spending charged to an advance straight from +// the purchase orders and expenses that point at it. Keeping it as an expression +// rather than a column means an advance can never disagree with the purchases behind it, +// whichever screen edited them. Cancelled spending never accounted for anything. +const cashAdvanceSettledAmountExpr = `( + COALESCE((SELECT SUM(po.total_amount) FROM purchase_orders po + WHERE po.cash_advance_id = cash_advances.id AND po.status <> 'cancelled'), 0) + + COALESCE((SELECT SUM(e.total) FROM expenses e + WHERE e.cash_advance_id = cash_advances.id AND e.status <> 'cancel'), 0) +)` + +// Money is stored to two decimals, so half a cent is the smallest gap that means +// anything. The filters use it for the same reason the mapper does. +const cashAdvanceAmountEpsilon = 0.005 + +type CashAdvanceRepositoryImpl struct { + db *gorm.DB +} + +func NewCashAdvanceRepositoryImpl(db *gorm.DB) *CashAdvanceRepositoryImpl { + return &CashAdvanceRepositoryImpl{db: db} +} + +func (r *CashAdvanceRepositoryImpl) Create(ctx context.Context, cashAdvance *entities.CashAdvance) error { + return r.db.WithContext(ctx).Create(cashAdvance).Error +} + +func (r *CashAdvanceRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.CashAdvance, error) { + var cashAdvance entities.CashAdvance + err := r.db.WithContext(ctx). + Model(&entities.CashAdvance{}). + Select("cash_advances.*, "+cashAdvanceSettledAmountExpr+" AS settled_amount"). + Preload("Outlet"). + Preload("TeamCategory"). + Where("cash_advances.id = ?", id). + First(&cashAdvance).Error + if err != nil { + return nil, err + } + return &cashAdvance, nil +} + +func (r *CashAdvanceRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.CashAdvance, error) { + var cashAdvance entities.CashAdvance + err := r.db.WithContext(ctx). + Model(&entities.CashAdvance{}). + Select("cash_advances.*, "+cashAdvanceSettledAmountExpr+" AS settled_amount"). + Preload("Outlet"). + Preload("TeamCategory"). + Where("cash_advances.id = ? AND cash_advances.organization_id = ?", id, organizationID). + First(&cashAdvance).Error + if err != nil { + return nil, err + } + return &cashAdvance, nil +} + +func (r *CashAdvanceRepositoryImpl) GetByCodeNumber(ctx context.Context, codeNumber string, organizationID uuid.UUID) (*entities.CashAdvance, error) { + var cashAdvance entities.CashAdvance + err := r.db.WithContext(ctx). + Where("code_number = ? AND organization_id = ?", codeNumber, organizationID). + First(&cashAdvance).Error + if err != nil { + return nil, err + } + return &cashAdvance, nil +} + +func (r *CashAdvanceRepositoryImpl) Update(ctx context.Context, cashAdvance *entities.CashAdvance) error { + // Omit associations so a preloaded TeamCategory or Outlet is not written back + // over the row it came from. + return r.db.WithContext(ctx).Omit(clause.Associations).Save(cashAdvance).Error +} + +func (r *CashAdvanceRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&entities.CashAdvance{}, "id = ?", id).Error +} + +func (r *CashAdvanceRepositoryImpl) List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.CashAdvance, int64, error) { + var cashAdvances []*entities.CashAdvance + var total int64 + + // Count on its own query: the select list carries a correlated subquery, which + // GORM would otherwise drag into the COUNT. + countQuery := applyCashAdvanceFilters(r.db.WithContext(ctx).Model(&entities.CashAdvance{}).Where("cash_advances.organization_id = ?", organizationID), filters) + if err := countQuery.Count(&total).Error; err != nil { + return nil, 0, err + } + + query := applyCashAdvanceFilters(r.db.WithContext(ctx).Model(&entities.CashAdvance{}).Where("cash_advances.organization_id = ?", organizationID), filters) + err := query. + Select("cash_advances.*, " + cashAdvanceSettledAmountExpr + " AS settled_amount"). + Preload("Outlet"). + Preload("TeamCategory"). + Order("cash_advances.issued_date DESC, cash_advances.created_at DESC"). + Limit(limit). + Offset(offset). + Find(&cashAdvances).Error + + return cashAdvances, total, err +} + +func applyCashAdvanceFilters(query *gorm.DB, filters map[string]interface{}) *gorm.DB { + for key, value := range filters { + switch key { + case "search": + if search, ok := value.(string); ok && search != "" { + pattern := "%" + strings.ToLower(search) + "%" + query = query.Where("LOWER(cash_advances.code_number) LIKE ? OR LOWER(cash_advances.description) LIKE ?", pattern, pattern) + } + case "status": + if status, ok := value.(string); ok && status != "" { + query = query.Where("cash_advances.status = ?", status) + } + case "outlet_id": + if outletID, ok := value.(uuid.UUID); ok { + query = query.Where("cash_advances.outlet_id = ?", outletID) + } + case "team_scope": + if teamScope, ok := value.(string); ok && teamScope != "" { + query = query.Where("cash_advances.team_scope = ?", teamScope) + } + case "team_category_id": + if teamCategoryID, ok := value.(uuid.UUID); ok { + query = query.Where("cash_advances.team_category_id = ?", teamCategoryID) + } + case "settlement_status": + query = applyCashAdvanceSettlementFilter(query, value) + case "start_date": + if startDate, ok := value.(time.Time); ok { + query = query.Where("cash_advances.issued_date >= ?", startDate) + } + case "end_date": + if endDate, ok := value.(time.Time); ok { + query = query.Where("cash_advances.issued_date <= ?", endDate) + } + } + } + + return query +} + +// applyCashAdvanceSettlementFilter reproduces in SQL what the mapper computes in Go: +// how much of the advance has been accounted for, by spending plus cash returned. +func applyCashAdvanceSettlementFilter(query *gorm.DB, value interface{}) *gorm.DB { + status, ok := value.(string) + if !ok || status == "" { + return query + } + + accounted := cashAdvanceSettledAmountExpr + " + cash_advances.returned_amount" + + switch status { + case constants.CashAdvanceSettlementOpen: + return query.Where(accounted+" <= ?", cashAdvanceAmountEpsilon) + case constants.CashAdvanceSettlementPartial: + return query. + Where(accounted+" > ?", cashAdvanceAmountEpsilon). + Where("cash_advances.amount - ("+accounted+") > ?", cashAdvanceAmountEpsilon) + case constants.CashAdvanceSettlementSettled: + return query. + Where(accounted+" > ?", cashAdvanceAmountEpsilon). + Where("cash_advances.amount - ("+accounted+") <= ?", cashAdvanceAmountEpsilon) + } + + return query +} + +// ListSettlements returns the spending charged to an advance, newest first. Purchase +// orders and expenses are two tables recording the same thing here, so they are +// read as one list. +func (r *CashAdvanceRepositoryImpl) ListSettlements(ctx context.Context, cashAdvanceID uuid.UUID) ([]*entities.CashAdvanceSettlement, error) { + query := fmt.Sprintf(` + SELECT '%s' AS type, po.id AS id, po.po_number AS number, + po.transaction_date AS date, po.total_amount AS amount, po.status AS status + FROM purchase_orders po + WHERE po.cash_advance_id = ? + UNION ALL + SELECT '%s' AS type, e.id AS id, e.code_number AS number, + e.transaction_date AS date, e.total AS amount, e.status AS status + FROM expenses e + WHERE e.cash_advance_id = ? + ORDER BY date DESC`, + constants.CashAdvanceSettlementTypePurchaseOrder, + constants.CashAdvanceSettlementTypeExpense, + ) + + var settlements []*entities.CashAdvanceSettlement + err := r.db.WithContext(ctx).Raw(query, cashAdvanceID, cashAdvanceID).Scan(&settlements).Error + return settlements, err +} + +// CountSettlements is what stops an advance being deleted once spending has been +// charged to it; the foreign keys would refuse anyway, but not with a readable error. +func (r *CashAdvanceRepositoryImpl) CountSettlements(ctx context.Context, cashAdvanceID uuid.UUID) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Raw(` + SELECT (SELECT COUNT(*) FROM purchase_orders WHERE cash_advance_id = ?) + + (SELECT COUNT(*) FROM expenses WHERE cash_advance_id = ?)`, + cashAdvanceID, cashAdvanceID).Scan(&count).Error + return count, err +} diff --git a/internal/router/router.go b/internal/router/router.go index 97d84ac..6da7d3f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -53,12 +53,13 @@ type Router struct { selfOrderHandler *handler.SelfOrderHandler productOutletPriceHandler *handler.ProductOutletPriceHandler expenseHandler *handler.ExpenseHandler + cashAdvanceHandler *handler.CashAdvanceHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -103,6 +104,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer selfOrderHandler: selfOrderHandler, productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator), expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator), + cashAdvanceHandler: handler.NewCashAdvanceHandler(cashAdvanceService, cashAdvanceValidator), redisClient: redisClient, } } @@ -478,6 +480,19 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { expenses.DELETE("/:id", r.expenseHandler.DeleteExpense) } + cashAdvances := protected.Group("/cash-advances") + cashAdvances.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) + { + cashAdvances.POST("", r.cashAdvanceHandler.CreateCashAdvance) + cashAdvances.GET("", r.cashAdvanceHandler.ListCashAdvances) + // Registered ahead of /:id so the picker path is not read as an id. + cashAdvances.GET("/teams", r.cashAdvanceHandler.ListCashAdvanceTeams) + cashAdvances.GET("/:id", r.cashAdvanceHandler.GetCashAdvance) + cashAdvances.PUT("/:id", r.cashAdvanceHandler.UpdateCashAdvance) + cashAdvances.PUT("/:id/status/:status", r.cashAdvanceHandler.UpdateCashAdvanceStatus) + cashAdvances.DELETE("/:id", r.cashAdvanceHandler.DeleteCashAdvance) + } + orderIngredientTransactions := protected.Group("/order-ingredient-transactions") orderIngredientTransactions.Use(r.authMiddleware.RequireAdminOrManager()) { diff --git a/internal/service/cash_advance_service.go b/internal/service/cash_advance_service.go new file mode 100644 index 0000000..4782649 --- /dev/null +++ b/internal/service/cash_advance_service.go @@ -0,0 +1,180 @@ +package service + +import ( + "context" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/transformer" + + "github.com/google/uuid" +) + +type CashAdvanceService interface { + CreateCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateCashAdvanceRequest) *contract.Response + UpdateCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdateCashAdvanceRequest) *contract.Response + DeleteCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + GetCashAdvanceByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + ListCashAdvances(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListCashAdvancesRequest) *contract.Response + UpdateCashAdvanceStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, status string) *contract.Response + ListCashAdvanceTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response +} + +type CashAdvanceServiceImpl struct { + cashAdvanceProcessor processor.CashAdvanceProcessor +} + +func NewCashAdvanceService(cashAdvanceProcessor processor.CashAdvanceProcessor) *CashAdvanceServiceImpl { + return &CashAdvanceServiceImpl{cashAdvanceProcessor: cashAdvanceProcessor} +} + +func (s *CashAdvanceServiceImpl) CreateCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateCashAdvanceRequest) *contract.Response { + modelReq, err := transformer.CreateCashAdvanceRequestToModel(req) + if err != nil { + errorResp := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.CashAdvanceServiceEntity, "Invalid date format. Use YYYY-MM-DD format") + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + cashAdvance, err := s.cashAdvanceProcessor.CreateCashAdvance(ctx, apctx.OrganizationID, outletFromContext(apctx), modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.CashAdvanceModelResponseToResponse(cashAdvance)) +} + +func (s *CashAdvanceServiceImpl) UpdateCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdateCashAdvanceRequest) *contract.Response { + modelReq, err := transformer.UpdateCashAdvanceRequestToModel(req) + if err != nil { + errorResp := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.CashAdvanceServiceEntity, "Invalid date format. Use YYYY-MM-DD format") + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + cashAdvance, err := s.cashAdvanceProcessor.UpdateCashAdvance(ctx, id, apctx.OrganizationID, modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.CashAdvanceModelResponseToResponse(cashAdvance)) +} + +func (s *CashAdvanceServiceImpl) DeleteCashAdvance(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + if err := s.cashAdvanceProcessor.DeleteCashAdvance(ctx, id, apctx.OrganizationID); err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(map[string]interface{}{ + "message": "Cash advance deleted successfully", + }) +} + +func (s *CashAdvanceServiceImpl) GetCashAdvanceByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + cashAdvance, err := s.cashAdvanceProcessor.GetCashAdvanceByID(ctx, id, apctx.OrganizationID) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.CashAdvanceModelResponseToResponse(cashAdvance)) +} + +func (s *CashAdvanceServiceImpl) ListCashAdvances(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListCashAdvancesRequest) *contract.Response { + modelReq := transformer.ListCashAdvancesRequestToModel(req) + + filters := make(map[string]interface{}) + if modelReq.Search != "" { + filters["search"] = modelReq.Search + } + if modelReq.Status != "" { + filters["status"] = modelReq.Status + } + if modelReq.SettlementStatus != "" { + filters["settlement_status"] = modelReq.SettlementStatus + } + if modelReq.TeamScope != "" { + filters["team_scope"] = modelReq.TeamScope + } + if modelReq.TeamCategoryID != nil { + filters["team_category_id"] = *modelReq.TeamCategoryID + } + // team spells out the same two filters in one value; the validator has already + // ruled out sending it together with them. + switch modelReq.Team { + case "": + case constants.PurchaseTeamScopeCentral: + filters["team_scope"] = constants.PurchaseTeamScopeCentral + default: + if teamCategoryID, err := uuid.Parse(modelReq.Team); err == nil { + filters["team_scope"] = constants.PurchaseTeamScopeCategory + filters["team_category_id"] = teamCategoryID + } + } + if modelReq.StartDate != nil { + filters["start_date"] = *modelReq.StartDate + } + if modelReq.EndDate != nil { + filters["end_date"] = *modelReq.EndDate + } + // Cash belongs to the drawer it came out of, so a user signed in to one outlet + // only sees that outlet's cash advances. + if outletID := outletFromContext(apctx); outletID != nil { + filters["outlet_id"] = *outletID + } + + cashAdvances, totalPages, err := s.cashAdvanceProcessor.ListCashAdvances(ctx, apctx.OrganizationID, filters, modelReq.Page, modelReq.Limit) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + responses := make([]contract.CashAdvanceResponse, len(cashAdvances)) + for i, cashAdvance := range cashAdvances { + if response := transformer.CashAdvanceModelResponseToResponse(cashAdvance); response != nil { + responses[i] = *response + } + } + + return contract.BuildSuccessResponse(contract.ListCashAdvancesResponse{ + CashAdvances: responses, + TotalCount: len(responses), + Page: modelReq.Page, + Limit: modelReq.Limit, + TotalPages: totalPages, + }) +} + +func (s *CashAdvanceServiceImpl) UpdateCashAdvanceStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, status string) *contract.Response { + cashAdvance, err := s.cashAdvanceProcessor.UpdateCashAdvanceStatus(ctx, id, apctx.OrganizationID, status) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.CashAdvanceModelResponseToResponse(cashAdvance)) +} + +func (s *CashAdvanceServiceImpl) ListCashAdvanceTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response { + teams, err := s.cashAdvanceProcessor.ListCashAdvanceTeams(ctx, apctx.OrganizationID, outletFromContext(apctx)) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.CashAdvanceServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.ListPurchaseTeamsModelResponseToResponse(teams)) +} + +// outletFromContext reads the caller's outlet as an optional value: an organization +// level user has none, and uuid.Nil is how that arrives on the context. +func outletFromContext(apctx *appcontext.ContextInfo) *uuid.UUID { + if apctx.OutletID == uuid.Nil { + return nil + } + + outletID := apctx.OutletID + return &outletID +} diff --git a/internal/transformer/cash_advance_transformer.go b/internal/transformer/cash_advance_transformer.go new file mode 100644 index 0000000..34d7002 --- /dev/null +++ b/internal/transformer/cash_advance_transformer.go @@ -0,0 +1,126 @@ +package transformer + +import ( + "time" + + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" +) + +func CreateCashAdvanceRequestToModel(req *contract.CreateCashAdvanceRequest) (*models.CreateCashAdvanceRequest, error) { + issuedDate, err := time.Parse("2006-01-02", req.IssuedDate) + if err != nil { + return nil, err + } + + var dueDate *time.Time + if req.DueDate != nil && *req.DueDate != "" { + parsed, err := time.Parse("2006-01-02", *req.DueDate) + if err != nil { + return nil, err + } + dueDate = &parsed + } + + return &models.CreateCashAdvanceRequest{ + OutletID: req.OutletID, + CodeNumber: req.CodeNumber, + TeamScope: req.TeamScope, + TeamCategoryID: req.TeamCategoryID, + Amount: req.Amount, + IssuedDate: issuedDate, + DueDate: dueDate, + Status: req.Status, + Description: req.Description, + }, nil +} + +func UpdateCashAdvanceRequestToModel(req *contract.UpdateCashAdvanceRequest) (*models.UpdateCashAdvanceRequest, error) { + var issuedDate *time.Time + if req.IssuedDate != nil && *req.IssuedDate != "" { + parsed, err := time.Parse("2006-01-02", *req.IssuedDate) + if err != nil { + return nil, err + } + issuedDate = &parsed + } + + var dueDate *time.Time + if req.DueDate != nil && *req.DueDate != "" { + parsed, err := time.Parse("2006-01-02", *req.DueDate) + if err != nil { + return nil, err + } + dueDate = &parsed + } + + return &models.UpdateCashAdvanceRequest{ + CodeNumber: req.CodeNumber, + TeamScope: req.TeamScope, + TeamCategoryID: req.TeamCategoryID, + Amount: req.Amount, + ReturnedAmount: req.ReturnedAmount, + IssuedDate: issuedDate, + DueDate: dueDate, + Status: req.Status, + Description: req.Description, + }, nil +} + +func ListCashAdvancesRequestToModel(req *contract.ListCashAdvancesRequest) *models.ListCashAdvancesRequest { + return &models.ListCashAdvancesRequest{ + Page: req.Page, + Limit: req.Limit, + Search: req.Search, + Status: req.Status, + SettlementStatus: req.SettlementStatus, + Team: req.Team, + TeamScope: req.TeamScope, + TeamCategoryID: req.TeamCategoryID, + StartDate: req.StartDate, + EndDate: req.EndDate, + } +} + +func CashAdvanceModelResponseToResponse(cashAdvance *models.CashAdvanceResponse) *contract.CashAdvanceResponse { + if cashAdvance == nil { + return nil + } + + response := &contract.CashAdvanceResponse{ + ID: cashAdvance.ID, + OrganizationID: cashAdvance.OrganizationID, + OutletID: cashAdvance.OutletID, + CodeNumber: cashAdvance.CodeNumber, + TeamScope: cashAdvance.TeamScope, + TeamCategoryID: cashAdvance.TeamCategoryID, + Amount: cashAdvance.Amount, + SettledAmount: cashAdvance.SettledAmount, + ReturnedAmount: cashAdvance.ReturnedAmount, + RemainingAmount: cashAdvance.RemainingAmount, + SettlementStatus: cashAdvance.SettlementStatus, + IssuedDate: cashAdvance.IssuedDate, + DueDate: cashAdvance.DueDate, + Status: cashAdvance.Status, + Description: cashAdvance.Description, + CreatedAt: cashAdvance.CreatedAt, + UpdatedAt: cashAdvance.UpdatedAt, + Team: PurchaseTeamModelToResponse(cashAdvance.Team), + } + + if cashAdvance.Settlements != nil { + response.Settlements = make([]contract.CashAdvanceSettlementResponse, len(cashAdvance.Settlements)) + for i, settlement := range cashAdvance.Settlements { + response.Settlements[i] = contract.CashAdvanceSettlementResponse{ + Type: settlement.Type, + ID: settlement.ID, + Number: settlement.Number, + Date: settlement.Date, + Amount: settlement.Amount, + Status: settlement.Status, + } + } + } + + return response +} diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go index bc8606c..6b6acb5 100644 --- a/internal/transformer/expense_transformer.go +++ b/internal/transformer/expense_transformer.go @@ -21,6 +21,7 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre Description: req.Description, Tax: req.Tax, Total: req.Total, + CashAdvanceID: req.CashAdvanceID, Items: items, } } @@ -46,6 +47,7 @@ func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.Upd Tax: req.Tax, Total: req.Total, Reserved1: req.Reserved1, + CashAdvanceID: req.CashAdvanceID, } if req.Items != nil { @@ -103,6 +105,7 @@ func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.E Tax: expense.Tax, Total: expense.Total, Reserved1: expense.Reserved1, + CashAdvanceID: expense.CashAdvanceID, CreatedAt: expense.CreatedAt, UpdatedAt: expense.UpdatedAt, Items: items, diff --git a/internal/transformer/purchase_order_transformer.go b/internal/transformer/purchase_order_transformer.go index 962ec19..518e94a 100644 --- a/internal/transformer/purchase_order_transformer.go +++ b/internal/transformer/purchase_order_transformer.go @@ -46,6 +46,7 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest) Message: req.Message, TeamScope: req.TeamScope, TeamCategoryID: req.TeamCategoryID, + CashAdvanceID: req.CashAdvanceID, Items: items, AttachmentFileIDs: req.AttachmentFileIDs, }, nil @@ -98,6 +99,7 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest) Message: req.Message, TeamScope: req.TeamScope, TeamCategoryID: req.TeamCategoryID, + CashAdvanceID: req.CashAdvanceID, Items: items, AttachmentFileIDs: req.AttachmentFileIDs, }, nil @@ -159,6 +161,7 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con TotalAmount: po.TotalAmount, TeamScope: po.TeamScope, TeamCategoryID: po.TeamCategoryID, + CashAdvanceID: po.CashAdvanceID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, Team: PurchaseTeamModelToResponse(po.Team), diff --git a/internal/validator/cash_advance_validator.go b/internal/validator/cash_advance_validator.go new file mode 100644 index 0000000..b1e404b --- /dev/null +++ b/internal/validator/cash_advance_validator.go @@ -0,0 +1,192 @@ +package validator + +import ( + "errors" + "strings" + "time" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + + "github.com/google/uuid" +) + +type CashAdvanceValidator interface { + ValidateCreateCashAdvanceRequest(req *contract.CreateCashAdvanceRequest) (error, string) + ValidateUpdateCashAdvanceRequest(req *contract.UpdateCashAdvanceRequest) (error, string) + ValidateListCashAdvancesRequest(req *contract.ListCashAdvancesRequest) (error, string) +} + +type CashAdvanceValidatorImpl struct{} + +func NewCashAdvanceValidator() *CashAdvanceValidatorImpl { + return &CashAdvanceValidatorImpl{} +} + +func (v *CashAdvanceValidatorImpl) ValidateCreateCashAdvanceRequest(req *contract.CreateCashAdvanceRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.CodeNumber) == "" { + return errors.New("code_number is required"), constants.MissingFieldErrorCode + } + if len(req.CodeNumber) > 50 { + return errors.New("code_number must be at most 50 characters"), constants.MalformedFieldErrorCode + } + + if req.OutletID != nil && *req.OutletID == uuid.Nil { + return errors.New("outlet_id cannot be empty"), constants.MalformedFieldErrorCode + } + + // A cash advance is cash handed to a team, so the team is not optional here the way + // it is on a purchase order: allowClear stays false and an empty scope is rejected. + if err, code := validatePurchaseTeamSelection(&req.TeamScope, req.TeamCategoryID, false); err != nil { + return err, code + } + + if req.Amount <= 0 { + return errors.New("amount must be greater than 0"), constants.MalformedFieldErrorCode + } + + issuedDate, err := time.Parse("2006-01-02", strings.TrimSpace(req.IssuedDate)) + if err != nil { + return errors.New("issued_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode + } + + if req.DueDate != nil { + if strings.TrimSpace(*req.DueDate) == "" { + return errors.New("due_date cannot be empty"), constants.MalformedFieldErrorCode + } + + dueDate, err := time.Parse("2006-01-02", *req.DueDate) + if err != nil { + return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode + } + if dueDate.Before(issuedDate) { + return errors.New("due_date must be after issued_date"), constants.MalformedFieldErrorCode + } + } + + if req.Status != nil && !constants.IsValidCashAdvanceStatus(*req.Status) { + return errors.New("status must be one of: " + strings.Join(constants.GetAllCashAdvanceStatuses(), ", ")), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *CashAdvanceValidatorImpl) ValidateUpdateCashAdvanceRequest(req *contract.UpdateCashAdvanceRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if req.CodeNumber != nil { + if strings.TrimSpace(*req.CodeNumber) == "" { + return errors.New("code_number cannot be empty"), constants.MalformedFieldErrorCode + } + if len(*req.CodeNumber) > 50 { + return errors.New("code_number must be at most 50 characters"), constants.MalformedFieldErrorCode + } + } + + // The team can be moved but never dropped, so clearing is not allowed here either. + if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, false); err != nil { + return err, code + } + + if req.Amount != nil && *req.Amount <= 0 { + return errors.New("amount must be greater than 0"), constants.MalformedFieldErrorCode + } + + if req.ReturnedAmount != nil && *req.ReturnedAmount < 0 { + return errors.New("returned_amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode + } + + var issuedDate *time.Time + if req.IssuedDate != nil { + if strings.TrimSpace(*req.IssuedDate) == "" { + return errors.New("issued_date cannot be empty"), constants.MalformedFieldErrorCode + } + parsed, err := time.Parse("2006-01-02", *req.IssuedDate) + if err != nil { + return errors.New("issued_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode + } + issuedDate = &parsed + } + + if req.DueDate != nil { + if strings.TrimSpace(*req.DueDate) == "" { + return errors.New("due_date cannot be empty"), constants.MalformedFieldErrorCode + } + dueDate, err := time.Parse("2006-01-02", *req.DueDate) + if err != nil { + return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode + } + if issuedDate != nil && dueDate.Before(*issuedDate) { + return errors.New("due_date must be after issued_date"), constants.MalformedFieldErrorCode + } + } + + if req.Status != nil && !constants.IsValidCashAdvanceStatus(*req.Status) { + return errors.New("status must be one of: " + strings.Join(constants.GetAllCashAdvanceStatuses(), ", ")), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *CashAdvanceValidatorImpl) ValidateListCashAdvancesRequest(req *contract.ListCashAdvancesRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if req.Page < 1 { + return errors.New("page must be at least 1"), constants.MalformedFieldErrorCode + } + + if req.Limit < 1 || req.Limit > 100 { + return errors.New("limit must be between 1 and 100"), constants.MalformedFieldErrorCode + } + + if req.Status != "" && !constants.IsValidCashAdvanceStatus(req.Status) { + return errors.New("status must be one of: " + strings.Join(constants.GetAllCashAdvanceStatuses(), ", ")), constants.MalformedFieldErrorCode + } + + if req.SettlementStatus != "" && !constants.IsValidCashAdvanceSettlementStatus(req.SettlementStatus) { + return errors.New("settlement_status must be one of: " + strings.Join(constants.GetAllCashAdvanceSettlementStatuses(), ", ")), constants.MalformedFieldErrorCode + } + + if req.Team != "" { + if req.TeamScope != "" || req.TeamCategoryID != nil { + return errors.New("team cannot be combined with team_scope or team_category_id"), constants.MalformedFieldErrorCode + } + + // Every cash advance has a team, so unlike purchases there is nothing to filter + // for "no team yet": only Pusat or a category id make sense here. + if req.Team != constants.PurchaseTeamScopeCentral { + if categoryID, err := uuid.Parse(req.Team); err != nil || categoryID == uuid.Nil { + return errors.New("team must be either central or a category id"), constants.MalformedFieldErrorCode + } + } + } + + if req.TeamScope != "" { + validScopes := []string{constants.PurchaseTeamScopeCategory, constants.PurchaseTeamScopeCentral} + if !contains(validScopes, req.TeamScope) { + return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode + } + + if req.TeamScope == constants.PurchaseTeamScopeCentral && req.TeamCategoryID != nil { + return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode + } + } + + if req.TeamCategoryID != nil && *req.TeamCategoryID == uuid.Nil { + return errors.New("team_category_id cannot be empty"), constants.MalformedFieldErrorCode + } + + if req.StartDate != nil && req.EndDate != nil && req.EndDate.Before(*req.StartDate) { + return errors.New("end_date must be after start_date"), constants.MalformedFieldErrorCode + } + + return nil, "" +} diff --git a/migrations/000087_create_cash_advances_table.down.sql b/migrations/000087_create_cash_advances_table.down.sql new file mode 100644 index 0000000..3d6c961 --- /dev/null +++ b/migrations/000087_create_cash_advances_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS cash_advances; diff --git a/migrations/000087_create_cash_advances_table.up.sql b/migrations/000087_create_cash_advances_table.up.sql new file mode 100644 index 0000000..462aa4c --- /dev/null +++ b/migrations/000087_create_cash_advances_table.up.sql @@ -0,0 +1,45 @@ +-- A cash advance is money handed to a team up front so the team can go shopping +-- (kasbon in the Indonesian UI). It is deliberately not an expense: while the money +-- sits with the team it is still the outlet's, and what was actually spent is read +-- from the purchase orders and expenses charged back to the advance. Nothing about +-- that spending is copied here. +CREATE TABLE cash_advances ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + outlet_id UUID NOT NULL REFERENCES outlets(id) ON DELETE CASCADE, + code_number VARCHAR(50) NOT NULL, + -- Same team shape as purchase_orders, with one difference: an advance is handed + -- to a team, so there is no "no team chosen yet" state and team_scope is NOT NULL. + team_scope VARCHAR(20) NOT NULL, + team_category_id UUID REFERENCES categories(id) ON DELETE RESTRICT, + amount DECIMAL(15,2) NOT NULL DEFAULT 0, + -- Cash the team brought back unspent. Spending is not stored: it is summed from + -- the purchase orders and expenses that point at this advance. + returned_amount DECIMAL(15,2) NOT NULL DEFAULT 0, + issued_date DATE NOT NULL, + due_date DATE, + status VARCHAR(20) NOT NULL DEFAULT 'draft', + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- Written as a CASE for the same reason as purchase_orders: an OR chain would + -- evaluate to NULL for an unexpected scope and a CHECK only rejects FALSE. + CONSTRAINT chk_cash_advances_team CHECK ( + CASE + WHEN team_scope = 'category' THEN team_category_id IS NOT NULL + WHEN team_scope = 'central' THEN team_category_id IS NULL + ELSE false + END + ), + CONSTRAINT chk_cash_advances_amounts CHECK (amount >= 0 AND returned_amount >= 0) +); + +-- Leading with organization_id means this also serves the plain per-organization +-- lookups, so there is no separate index on that column. +CREATE UNIQUE INDEX idx_cash_advances_organization_id_code_number ON cash_advances(organization_id, code_number); +CREATE INDEX idx_cash_advances_outlet_id ON cash_advances(outlet_id); +CREATE INDEX idx_cash_advances_team_category_id ON cash_advances(team_category_id); +CREATE INDEX idx_cash_advances_team_scope ON cash_advances(team_scope); +CREATE INDEX idx_cash_advances_issued_date ON cash_advances(issued_date); +CREATE INDEX idx_cash_advances_status ON cash_advances(status); diff --git a/migrations/000088_add_cash_advance_to_spending.down.sql b/migrations/000088_add_cash_advance_to_spending.down.sql new file mode 100644 index 0000000..68c239e --- /dev/null +++ b/migrations/000088_add_cash_advance_to_spending.down.sql @@ -0,0 +1,15 @@ +DROP INDEX IF EXISTS idx_expenses_cash_advance_id; + +ALTER TABLE expenses + DROP CONSTRAINT IF EXISTS fk_expenses_cash_advance; + +ALTER TABLE expenses + DROP COLUMN IF EXISTS cash_advance_id; + +DROP INDEX IF EXISTS idx_purchase_orders_cash_advance_id; + +ALTER TABLE purchase_orders + DROP CONSTRAINT IF EXISTS fk_purchase_orders_cash_advance; + +ALTER TABLE purchase_orders + DROP COLUMN IF EXISTS cash_advance_id; diff --git a/migrations/000088_add_cash_advance_to_spending.up.sql b/migrations/000088_add_cash_advance_to_spending.up.sql new file mode 100644 index 0000000..8d2a14c --- /dev/null +++ b/migrations/000088_add_cash_advance_to_spending.up.sql @@ -0,0 +1,24 @@ +-- Spending paid out of a cash advance points back at it. This is how an advance is +-- accounted for: the team's purchases and expenses are the settlement, so the advance +-- itself never carries a copy of what was bought. +-- RESTRICT rather than SET NULL: dropping an advance that still has spending on it +-- would leave that spending looking like it came straight out of the drawer. +ALTER TABLE purchase_orders + ADD COLUMN IF NOT EXISTS cash_advance_id UUID; + +ALTER TABLE purchase_orders + ADD CONSTRAINT fk_purchase_orders_cash_advance + FOREIGN KEY (cash_advance_id) REFERENCES cash_advances(id) ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS idx_purchase_orders_cash_advance_id + ON purchase_orders(cash_advance_id); + +ALTER TABLE expenses + ADD COLUMN IF NOT EXISTS cash_advance_id UUID; + +ALTER TABLE expenses + ADD CONSTRAINT fk_expenses_cash_advance + FOREIGN KEY (cash_advance_id) REFERENCES cash_advances(id) ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS idx_expenses_cash_advance_id + ON expenses(cash_advance_id); -- 2.54.0 From 992bb04816699f21c60168a46aa3d7e946e06657 Mon Sep 17 00:00:00 2001 From: efrilm Date: Sun, 6 Sep 2026 17:14:42 +0700 Subject: [PATCH 06/47] feat(order): support weight-based products MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Products like fish are sold per weighing (4.2 ons, 5.6 ons), which the order line could not represent: quantity is INTEGER and prices are always computed as quantity * unit_price. Model one weighing as one order line. quantity stays INTEGER and keeps meaning "how many items"; the measured amount goes into a new nullable order_items.weight, and the line is priced weight * unit_price. Two weighings of the same product are two lines, never merged into one. Keeping quantity integral avoids float comparisons in void, refund and split bill, where accumulated rounding error would silently misbehave — "1.4 + 1.4 + 1.4" is not 4.2 in float64, which would leave a fully paid split-bill item marked unpaid. BillableQuantity() is now the single place that decides between weight and count; every price and cost calculation goes through it. Missing one would bill a 4.2 ons fish as a single ons — wrong money, no error. Two database constraints back the design: a weighed line always carries a positive weight, and its quantity is pinned to 1. The latter also makes void all-or-nothing for weighed lines, so the row-splitting branch can never produce a zero-weight remainder row. Also wires product.unit_id through the API, which was previously not settable at all, and corrects the misleading comment on the request's unit_price field — that value has never been used; price always comes from the database. Design notes and the audit of every price multiplication site are in docs/rfc-weight-based-products.md. Co-Authored-By: Claude Opus 5 (1M context) --- docs/rfc-weight-based-products.md | 362 ++++++++++++++++++ internal/constants/product.go | 17 + internal/contract/analytics_contract.go | 2 + internal/contract/order_contract.go | 62 +-- internal/contract/product_contract.go | 6 + internal/contract/self_order_contract.go | 1 + internal/entities/analytics.go | 1 + internal/entities/order_item.go | 55 ++- internal/entities/order_item_test.go | 104 +++++ internal/entities/product.go | 1 + internal/handler/self_order_handler.go | 1 + internal/mappers/order_mapper.go | 11 + internal/mappers/product_mapper.go | 18 + internal/models/analytics.go | 2 + internal/models/order.go | 9 +- internal/models/product.go | 4 + internal/processor/analytics_processor.go | 2 + internal/processor/order_processor.go | 85 ++-- internal/processor/order_weight_test.go | 106 +++++ internal/processor/split_bill_processor.go | 6 +- internal/repository/analytics_repository.go | 7 +- internal/repository/order_item_repository.go | 21 +- internal/repository/order_repository.go | 3 + internal/service/report_service.go | 12 + internal/transformer/analytics_transformer.go | 3 + internal/transformer/order_transformer.go | 8 + internal/transformer/product_transformer.go | 11 + .../000089_add_weight_to_order_items.down.sql | 15 + .../000089_add_weight_to_order_items.up.sql | 30 ++ templates/daily_transaction.html | 2 + 30 files changed, 901 insertions(+), 66 deletions(-) create mode 100644 docs/rfc-weight-based-products.md create mode 100644 internal/constants/product.go create mode 100644 internal/entities/order_item_test.go create mode 100644 internal/processor/order_weight_test.go create mode 100644 migrations/000089_add_weight_to_order_items.down.sql create mode 100644 migrations/000089_add_weight_to_order_items.up.sql diff --git a/docs/rfc-weight-based-products.md b/docs/rfc-weight-based-products.md new file mode 100644 index 0000000..2a0f299 --- /dev/null +++ b/docs/rfc-weight-based-products.md @@ -0,0 +1,362 @@ +# RFC: Produk Timbangan (Weight-Based Products) + +**Status:** Diimplementasikan (migrasi `000089`) +**Tanggal:** 2026-09-05, diperbarui 2026-09-06 +**Scope:** Product, Order, Void/Refund, Report +**Out of scope:** Inventory / pengurangan stok otomatis (lihat §8) + +--- + +## 1. Masalah + +Sistem mengasumsikan setiap produk dijual dalam satuan diskrit. `order_items.quantity` +bertipe `INTEGER` dengan `CHECK (quantity > 0)`, dan harga dihitung +`quantity × unit_price` di seluruh jalur order, void, refund, dan split bill. + +Produk seperti Ikan Tude dijual per timbangan. Pelanggan memesan Ikan Tude 4,2 ons, +lalu memesan Ikan Tude lagi 5,6 ons. Keduanya adalah **dua ikan berbeda yang ditimbang +terpisah** — bukan satu baris berisi 9,8. + +Angka 4,2 itu **berat**, bukan cacah. Sistem belum punya tempat untuk menyimpannya. + +> **Catatan satuan.** RFC ini tidak mengasumsikan satuan tertentu. Satuan produk +> ditentukan `products.unit_id` yang merujuk tabel `units` — bisa ons, kg, gram, atau +> apa pun yang didefinisikan organisasi. Contoh memakai **ons** karena itu kasus yang +> sedang dikerjakan; tidak ada bagian desain ini yang bergantung padanya. + +--- + +## 2. Keputusan Inti + +**Satu penimbangan = satu baris `order_items`.** + +| | Baris 1 | Baris 2 | +|---|---|---| +| Ikan Tude 4,2 ons | `quantity = 1`, `weight = 4.2` | | +| Ikan Tude 5,6 ons | | `quantity = 1`, `weight = 5.6` | + +`quantity` tetap `INTEGER` dan tetap berarti "berapa banyak barang". Berat masuk ke +kolom baru. Dua baris tidak pernah digabung menjadi `9.8`, karena keduanya memang dua +ikan yang berbeda. + +### Kenapa bukan `quantity = 4.2` + +Alternatif yang sempat dipertimbangkan adalah mengubah `quantity` menjadi +`DECIMAL(12,3)`. Model itu ditolak karena tiga alasan: + +1. **Menghapus jejak barang.** `4.2` dan `5.6` yang digabung jadi `9.8` kehilangan + informasi bahwa ada dua ikan. Tidak bisa direkonstruksi. +2. **Merusak agregasi lintas produk.** `SUM(quantity)` untuk laporan + "total item terjual" akan menjumlahkan ons dengan porsi — angka tanpa arti, yang + bahkan berubah nilainya bila satuan produk diganti dari ons ke kg tanpa ada apa pun + yang berubah di dunia nyata. +3. **Membawa masalah presisi float ke seluruh sistem.** Perbandingan quantity dipakai + di void, refund, dan split bill. Dengan float, `1,4 + 1,4 + 1,4` tidak sama dengan + `4,2` — split bill "bagi rata bertiga" akan gagal menandai item lunas meski uang + sudah diterima penuh. Semua itu tidak terjadi bila `quantity` tetap integer. + +Konsekuensi langsung dari keputusan ini: **tidak diperlukan helper perbandingan +epsilon.** Berat tidak pernah dibandingkan, hanya dikalikan. + +--- + +## 3. Prinsip + +**P1 — Baris transaksi adalah snapshot yang beku.** +`order_items` sudah menyimpan `unit_price` dan `unit_cost` sebagai salinan, bukan join +ke `products`. Satuan mendapat perlakuan sama: mengubah master data tidak boleh +mengubah arti transaksi yang sudah terjadi. + +**P2 — Perhitungan harga baris hanya ada di satu tempat.** +Setelah RFC ini ada dua rumus (`quantity × harga` dan `weight × harga`). Tidak boleh +ada perkalian harga yang tersebar; semuanya memanggil satu fungsi. + +**P3 — Harga tetap otoritas backend.** +Klien tidak pernah mengirim harga. Backend membacanya dari `products` / +`product_outlet_prices` seperti sekarang. + +**P4 — Berat boleh dijumlahkan dalam satu produk, tidak boleh antar produk.** +`SUM(weight)` untuk satu produk bermakna ("terjual 47,3 ons"). Lintas produk dengan +satuan berbeda tidak bermakna. + +--- + +## 4. Perubahan Skema + +```sql +-- Products: cara jual +ALTER TABLE products + ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit' + CHECK (sell_by IN ('unit', 'weight')); + +-- Order items: berat + snapshot satuan +ALTER TABLE order_items + ADD COLUMN weight DECIMAL(12,3), + ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT; + +ALTER TABLE order_items + ADD CONSTRAINT chk_order_items_weight_positive + CHECK (weight IS NULL OR weight > 0), + ADD CONSTRAINT chk_order_items_weight_single_line + CHECK (weight IS NULL OR quantity = 1); +``` + +**Catatan:** + +- `weight` **nullable**. `NULL` berarti produk satuan biasa — seluruh data lama valid + tanpa backfill, dan perilakunya tidak berubah sama sekali. +- `chk_order_items_weight_single_line` menegakkan keputusan §2 di level database: + baris berbobot selalu `quantity = 1`. Ini yang membuat `BillableQuantity()` tidak + ambigu dan membuat void otomatis bersifat utuh (§6). +- `quantity` **tidak berubah tipe**. `CHECK (quantity > 0)` yang sudah ada tetap + berlaku. +- `DECIMAL(12,3)` konsisten dengan `inventory_movements.quantity` yang sudah memakai + presisi sama. +- Tidak ada `weighed_unit`. Karena satu baris memang satu barang, "ikan curah" dan + "ikan per ekor" berperilaku identik — pembedaan itu tidak punya konsekuensi. + +--- + +## 5. Perhitungan Harga + +Satu-satunya tempat yang boleh mengalikan harga (P2): + +```go +// BillableQuantity mengembalikan pengali harga untuk baris ini: +// berat bila produk dijual per timbangan, jumlah bila dijual per satuan. +// Baris berbobot dijamin quantity = 1 oleh constraint DB. +func (oi *OrderItem) BillableQuantity() float64 { + if oi.Weight != nil { + return *oi.Weight + } + return float64(oi.Quantity) +} + +func (oi *OrderItem) CalculateTotalPrice() { + oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice) +} + +func (oi *OrderItem) CalculateTotalCost() { + oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost) +} +``` + +`unit_price` tetap berarti **harga per satu satuan produk** (per ons). Tidak ada faktor +konversi yang menyelinap ke perhitungan uang. + +### Titik yang harus diganti + +Ini bagian paling berisiko dari RFC. Setiap perkalian harga yang terlewat akan +menghitung `1 × harga_per_ons` — ikan 4,2 ons ditagih seharga 1 ons. **Salah uang, +bukan salah tampilan**, dan tidak memicu error apa pun. + +| Lokasi | Sekarang | +|---|---| +| `processor/order_processor.go:197-198` | buat order | +| `processor/order_processor.go:330-331` | tambah item ke order | +| `processor/order_processor.go:605-606` | jumlah & HPP yang di-void | +| `processor/order_processor.go:723` | jumlah refund | +| `processor/split_bill_processor.go:143` | hitung jumlah split | +| `processor/split_bill_processor.go:189` | catat pembayaran | +| `processor/split_bill_processor.go:231` | metadata pembayaran | +| `repository/order_item_repository.go:113` | jumlah void penuh | + +Implementasi menemukan **lima titik tambahan** di luar daftar di atas, semuanya di jalur +inventory movement dan resep bahan yang tidak terlihat saat RFC ini ditulis: + +| Lokasi | Status | +|---|---| +| `order_processor.go:1056` `createInventoryMovement` | mati (0 pemanggil), tetap diperbaiki | +| `order_processor.go:1354` `prepareProductInventoryMovement` | **hidup** | +| `order_processor.go:1420` `prepareIngredientRecipeItem` | **hidup** | +| `order_processor.go:1518` `prepareRefundProductInventoryMovement` | mati (0 pemanggil), tetap diperbaiki | +| `order_processor.go:1584` `prepareRefundedIngredientRecipeItem` | **hidup** | + +Tiga yang hidup penting: tanpa perbaikan, konsumsi bahan untuk ikan 4,2 ons akan +dihitung sebagai 1 satuan resep. + +### Verifikasi + +```bash +grep -rn "Quantity) \* \|Quantity \* " --include=*.go internal/ \ + | grep -iE "price|cost" | grep -v BillableQuantity | grep -v totalIngredientQuantity +``` + +Hasilnya **tidak kosong** — tersisa tujuh baris, semuanya sudah diperiksa dan aman: + +- `mappers/inventory_movement_mapper.go:129` dan + `processor/inventory_movement_processor.go:69` — penyesuaian stok manual, bukan + baris order. +- `repository/order_item_repository.go:144,146,147,165,166` — cabang void sebagian, + yang baris berbobot tidak pernah jangkau karena dijaga `orderItem.IsWeighed()`. + +Bila daftar ini bertambah di kemudian hari, baris barunya harus diperiksa satu per satu. + +--- + +## 6. Void, Refund, Split Bill + +**Tidak ada perubahan logika.** Ini konsekuensi menyenangkan dari `quantity` yang tetap +integer. + +**Void.** `VoidOrderItem` (`repository/order_item_repository.go:104`) bercabang pada +`voidQuantity >= orderItem.Quantity`. Untuk baris berbobot, `quantity` dijamin `1` dan +`voidQuantity` minimal `1`, sehingga **selalu** masuk cabang void penuh. Cabang +pemecahan baris tidak pernah tersentuh, sehingga tidak mungkin lahir baris sisa +berbobot nol. Yang berubah hanya perhitungan `voidedAmount` di baris 113 (§5). + +**Refund.** Sama — refund baris berbobot bersifat utuh. Hanya `refundAmount` di +`order_processor.go:723` yang perlu memakai `BillableQuantity()`. + +**Split bill.** `payment_order_items.quantity` tetap `INTEGER`. Untuk baris berbobot +nilainya `0` atau `1` — bayar penuh atau tidak sama sekali. Seluruh perbandingan di +`split_bill_processor.go` tetap aritmatika bilangan bulat, sehingga masalah presisi +float tidak pernah muncul. Hanya perhitungan `itemAmount` (baris 143 dan 189) yang +berubah. + +**Batasan yang diterima:** refund atau void **sebagian berat** (mengembalikan 1 ons +dari baris 4,2 ons) tidak didukung. Untuk barang yang sudah ditimbang dan diserahkan, +koreksi sebagian pada praktiknya berarti salah timbang — yang penanganan benarnya +adalah void baris itu lalu input ulang, bukan mengubah berat baris yang sudah tercatat. +Ini menjaga jejak audit tetap jujur. + +--- + +## 7. Validasi & Tampilan + +### 7.1 Aturan validasi + +Divalidasi di processor saat membuat / menambah item, di mana produk sudah dimuat: + +| `products.sell_by` | Aturan | +|---|---| +| `unit` | `weight` harus kosong. Bila dikirim → tolak. | +| `weight` | `weight` wajib ada dan `> 0`. `quantity` dipaksa `1`. | + +`unit_id` di `order_items` diisi dari `products.unit_id` saat baris dibuat (P1) — +bukan dibaca lewat join saat ditampilkan. + +Berat dibulatkan ke 3 desimal saat masuk, agar nilai tersimpan selalu sama dengan +nilai yang divalidasi. + +### 7.2 Tampilan + +`templates/daily_transaction.html:539` mencetak `{{$item.Quantity}}`. Untuk baris +berbobot ini akan menampilkan `1`, bukan `4,2 ons`. Perlu bercabang pada `weight`. + +Response API menambah `weight` dan `unit` pada item, agar frontend dan struk dapat +menampilkan `4,2 ons × Rp 4.500` alih-alih `1 × Rp 4.500`. + +--- + +## 8. Report + +**Tidak ada perubahan yang wajib.** Karena `quantity` tetap integer dan tetap berarti +"berapa banyak barang": + +- `SUM(oi.quantity)` sebagai `total_items` tetap bermakna dan tetap konsisten lintas + produk — 2 ikan tetap dihitung 2, bukan 9,8 ons. +- `QuantitySold` tetap `int64`. Tidak ada pemotongan pecahan. +- `average_price = SUM(total_price) / SUM(quantity)` menjadi "rata-rata harga per + ekor", yang tetap merupakan angka bermakna. + +**Tambahan opsional** — melaporkan berat terjual, hanya pada laporan **per produk** +(P4): + +```sql +COALESCE(SUM(oi.weight), 0) AS weight_sold +``` + +Tidak boleh dipakai pada agregat lintas produk, karena akan menjumlahkan satuan yang +berbeda. + +--- + +## 9. Di Luar Scope + +**Pengurangan stok otomatis.** `adjustInventoryWithTransaction` +(`order_processor.go:1177`) dan `adjustIngredientInventoryWithTransaction` +(`order_processor.go:920`) terdefinisi tetapi **tidak pernah dipanggil dari mana pun** — +sudah diverifikasi se-repo. Endpoint CRUD inventory berfungsi; pengurangan stok saat +penjualan tidak tersambung. + +Konsekuensi untuk RFC ini: `inventory.quantity` yang masih `int` tidak menghalangi +apa pun. + +Catatan untuk nanti bila jalur stok disambungkan: + +- Stok produk timbangan harus berkurang sebesar `weight`, bukan `quantity` — kalau + tidak, menjual ikan 4,2 ons hanya mengurangi stok sebanyak 1. +- `inventory.quantity` perlu menjadi `DECIMAL(12,3)` lebih dulu. Biayanya hampir nol + sekarang (3 call site, tanpa data historis); jauh lebih mahal setelah berjalan. +- `order_processor.go:946` berisi `deltaInt := int(delta)` yang memotong pecahan. + Kode ini mati, jadi bukan kebocoran aktif — tetapi bila disambungkan tanpa + diperbaiki, konsumsi bahan di bawah 1 unit akan hilang diam-diam. + +Kedua fungsi mati itu sebaiknya **dihapus atau disambungkan**, jangan dibiarkan +menggantung — komentar di dalamnya ditulis seolah-olah aktif. + +--- + +## 10. Temuan Sampingan: `unit_price` pada request diabaikan + +`CreateOrderItemRequest.UnitPrice` (`contract/order_contract.go:46`) berkomentar +*"Optional, will use database price if not provided"*. Kenyataannya field ini **tidak +pernah dipakai** — satu-satunya yang menyentuhnya adalah validasi `< 0` di +`service/order_service.go:431` dan `:474`. Processor selalu membaca harga dari +`products` / `product_outlet_prices`. + +Perilaku sekarang sudah benar dan sesuai P3. Yang salah hanya komentarnya, yang +menyiratkan klien bisa mengirim harga. Sebaiknya field itu **dihapus** dari contract, +atau komentarnya dikoreksi menjadi keterangan bahwa harga selalu diambil dari database. + +Dibiarkan seperti sekarang, ini mengundang frontend mengirim harga dan menyangka +berhasil, padahal diabaikan diam-diam. + +--- + +## 11. Urutan Implementasi + +1. **Migrasi skema** (§4). Aman: semua kolom nullable atau ber-default, data lama + tidak tersentuh. +2. **`BillableQuantity()` + `CalculateTotalPrice()` / `CalculateTotalCost()`** (§5). +3. **Ganti 8 titik perkalian harga** (§5) lalu jalankan dua `grep` verifikasi. +4. **Field kontrak**: `weight` pada request order & self-order, `weight` + `unit` pada + response. +5. **Validasi `sell_by`** (§7.1). +6. **Template & tampilan struk** (§7.2). +7. *(Opsional)* `weight_sold` pada laporan per produk (§8). + +Langkah 1-4 membuat produk timbangan dapat dijual dengan harga yang benar. Langkah 5 +mencegah data tidak konsisten masuk. Langkah 6 membuat struk terbaca benar. + +--- + +## 12. Risiko + +| Risiko | Dampak | Mitigasi | +|---|---|---| +| Satu titik perkalian harga terlewat | Ikan 4,2 ons ditagih seharga 1 ons — salah uang, tanpa error | Dua `grep` verifikasi di §5; uji satu order timbangan lewat setiap jalur (create, tambah item, void, refund, split bill) | +| `weight` dikirim untuk produk `unit` | Harga baris salah total | Validasi §7.1 + constraint DB | +| `quantity > 1` pada baris berbobot | `BillableQuantity()` ambigu | Dicegah `chk_order_items_weight_single_line` di level DB | +| Klien lama tidak mengirim `weight` | Produk timbangan ditagih 1 satuan | Validasi §7.1 menolak, bukan mendiamkan | +| Struk menampilkan `1` alih-alih `4,2 ons` | Pelanggan bingung, kasir kehilangan kepercayaan | §7.2 | + +--- + +## 13. Pertanyaan Terbuka + +1. **Pembulatan uang — diputuskan sementara, perlu konfirmasi.** `RoundMoney` + membulatkan ke **2 desimal**, mengikuti presisi kolom `decimal(10,2)` yang sudah + dipakai semua nilai uang. Jadi `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`. + + Ini pilihan paling tidak mengejutkan dan konsisten dengan data lama, tetapi **bukan** + pembulatan ke rupiah utuh. Bila kasir harus menerima uang dalam rupiah penuh (atau + kelipatan Rp 100/500), ubah `RoundMoney` di `entities/order_item.go` — satu tempat, + dan lakukan **sebelum** ada transaksi timbangan, karena setelahnya data lama dan + baru akan mengikuti aturan berbeda. +2. **Presisi input berat.** Apakah `4,237 ons` (resolusi 0,1 gram) valid, atau input + harus dibatasi ke kelipatan tertentu sesuai resolusi timbangan? Bila perlu dibatasi, + tambahkan `products.min_weight_increment`. +3. **Sumber angka timbangan** — kasir mengetik manual atau timbangan tersambung? Bila + tersambung, ada urusan tara dan pembacaan stabil yang berada di luar RFC ini. diff --git a/internal/constants/product.go b/internal/constants/product.go new file mode 100644 index 0000000..cb79eaf --- /dev/null +++ b/internal/constants/product.go @@ -0,0 +1,17 @@ +package constants + +// SellBy describes how a product's quantity is expressed on an order line. +type SellBy = string + +const ( + // SellByUnit is the default: the line counts discrete items (2 portions, 3 bottles). + SellByUnit SellBy = "unit" + // SellByWeight means the line carries a weighed amount. One weighing is one line, + // so quantity stays 1 and the measured value lives in order_items.weight. + SellByWeight SellBy = "weight" +) + +// IsValidSellBy reports whether s is a supported sell_by value. +func IsValidSellBy(s string) bool { + return s == SellByUnit || s == SellByWeight +} diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 918be35..1d2045f 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -198,6 +198,7 @@ type ProductAnalyticsData struct { CategoryName string `json:"category_name"` CategoryOrder int `json:"category_order"` QuantitySold int64 `json:"quantity_sold"` + WeightSold float64 `json:"weight_sold"` Revenue float64 `json:"revenue"` AveragePrice float64 `json:"average_price"` OrderCount int64 `json:"order_count"` @@ -324,6 +325,7 @@ type ParentCategoryAnalyticsProductData struct { ProductSku string `json:"product_sku"` ProductPrice float64 `json:"product_price"` QuantitySold int64 `json:"quantity_sold"` + WeightSold float64 `json:"weight_sold"` Revenue float64 `json:"revenue"` AveragePrice float64 `json:"average_price"` OrderCount int64 `json:"order_count"` diff --git a/internal/contract/order_contract.go b/internal/contract/order_contract.go index 237874f..80ea44f 100644 --- a/internal/contract/order_contract.go +++ b/internal/contract/order_contract.go @@ -40,13 +40,16 @@ type UpdateOrderRequest struct { } type CreateOrderItemRequest struct { - ProductID uuid.UUID `json:"product_id" validate:"required"` - ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"` - Quantity int `json:"quantity" validate:"required,min=1"` - UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Optional, will use database price if not provided - Modifiers []map[string]interface{} `json:"modifiers,omitempty"` - Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + ProductID uuid.UUID `json:"product_id" validate:"required"` + ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"` + Quantity int `json:"quantity" validate:"required,min=1"` + // Weight is required for products with sell_by = "weight" and must be absent otherwise. + // One weighing is one line, so quantity stays 1 and the line is priced weight * unit_price. + Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"` + UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Ignored: price always comes from the database + Modifiers []map[string]interface{} `json:"modifiers,omitempty"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } type UpdateOrderItemRequest struct { @@ -92,26 +95,31 @@ type OrderResponse struct { } type OrderItemResponse struct { - ID uuid.UUID `json:"id"` - OrderID uuid.UUID `json:"order_id"` - ProductID uuid.UUID `json:"product_id"` - ProductName string `json:"product_name"` - ProductVariantID *uuid.UUID `json:"product_variant_id"` - ProductVariantName *string `json:"product_variant_name,omitempty"` - CategoryID *uuid.UUID `json:"category_id,omitempty"` - CategoryName *string `json:"category_name,omitempty"` - Quantity int `json:"quantity"` - UnitPrice float64 `json:"unit_price"` - TotalPrice float64 `json:"total_price"` - Modifiers []map[string]interface{} `json:"modifiers"` - Notes *string `json:"notes,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - PrinterType string `json:"printer_type"` - PrintToChecker bool `json:"print_to_checker"` - PaidQuantity int `json:"paid_quantity"` + ID uuid.UUID `json:"id"` + OrderID uuid.UUID `json:"order_id"` + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + ProductVariantID *uuid.UUID `json:"product_variant_id"` + ProductVariantName *string `json:"product_variant_name,omitempty"` + CategoryID *uuid.UUID `json:"category_id,omitempty"` + CategoryName *string `json:"category_name,omitempty"` + Quantity int `json:"quantity"` + // Weight is set only for products sold by weight; the line is priced weight * unit_price. + Weight *float64 `json:"weight,omitempty"` + UnitID *uuid.UUID `json:"unit_id,omitempty"` + UnitName *string `json:"unit_name,omitempty"` + UnitAbbreviation *string `json:"unit_abbreviation,omitempty"` + UnitPrice float64 `json:"unit_price"` + TotalPrice float64 `json:"total_price"` + Modifiers []map[string]interface{} `json:"modifiers"` + Notes *string `json:"notes,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PrinterType string `json:"printer_type"` + PrintToChecker bool `json:"print_to_checker"` + PaidQuantity int `json:"paid_quantity"` } type ListOrdersQuery struct { diff --git a/internal/contract/product_contract.go b/internal/contract/product_contract.go index 61c9e90..90a90ea 100644 --- a/internal/contract/product_contract.go +++ b/internal/contract/product_contract.go @@ -18,6 +18,8 @@ type CreateProductRequest struct { ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"` PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"` PrintToChecker *bool `json:"print_to_checker,omitempty"` + UnitID *uuid.UUID `json:"unit_id,omitempty"` + SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"` Metadata map[string]interface{} `json:"metadata,omitempty"` IsActive *bool `json:"is_active,omitempty"` Variants []CreateProductVariantRequest `json:"variants,omitempty"` @@ -38,6 +40,8 @@ type UpdateProductRequest struct { ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"` PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"` PrintToChecker *bool `json:"print_to_checker,omitempty"` + UnitID *uuid.UUID `json:"unit_id,omitempty"` + SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"` Metadata map[string]interface{} `json:"metadata,omitempty"` IsActive *bool `json:"is_active,omitempty"` ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` @@ -73,6 +77,8 @@ type ProductResponse struct { BusinessType string `json:"business_type"` ImageURL *string `json:"image_url"` PrinterType string `json:"printer_type"` + UnitID *uuid.UUID `json:"unit_id,omitempty"` + SellBy string `json:"sell_by"` PrintToChecker bool `json:"print_to_checker"` Metadata map[string]interface{} `json:"metadata"` IsActive bool `json:"is_active"` diff --git a/internal/contract/self_order_contract.go b/internal/contract/self_order_contract.go index 8a4e7fd..7e97c7c 100644 --- a/internal/contract/self_order_contract.go +++ b/internal/contract/self_order_contract.go @@ -58,6 +58,7 @@ type SelfOrderCreateOrderItem struct { ProductID uuid.UUID `json:"product_id" validate:"required"` ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"` Quantity int `json:"quantity" validate:"required,min=1"` + Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"` Notes *string `json:"notes,omitempty"` } diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index fc38018..87b9644 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -114,6 +114,7 @@ type ProductAnalytics struct { CategoryName string `json:"category_name"` CategoryOrder int `json:"category_order"` QuantitySold int64 `json:"quantity_sold"` + WeightSold float64 `json:"weight_sold"` Revenue float64 `json:"revenue"` AveragePrice float64 `json:"average_price"` OrderCount int64 `json:"order_count"` diff --git a/internal/entities/order_item.go b/internal/entities/order_item.go index 72cbf58..189bcc3 100644 --- a/internal/entities/order_item.go +++ b/internal/entities/order_item.go @@ -4,6 +4,7 @@ import ( "database/sql/driver" "encoding/json" "errors" + "math" "time" "github.com/google/uuid" @@ -47,6 +48,8 @@ type OrderItem struct { ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"` ProductVariantID *uuid.UUID `gorm:"type:uuid;index" json:"product_variant_id"` Quantity int `gorm:"not null" json:"quantity" validate:"required,min=1"` + Weight *float64 `gorm:"type:decimal(12,3)" json:"weight,omitempty"` + UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id,omitempty"` UnitPrice float64 `gorm:"type:decimal(10,2);not null" json:"unit_price" validate:"required,min=0"` TotalPrice float64 `gorm:"type:decimal(10,2);not null" json:"total_price" validate:"required,min=0"` UnitCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"unit_cost"` @@ -68,6 +71,7 @@ type OrderItem struct { Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"` Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,omitempty"` + Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"` } func (oi *OrderItem) BeforeCreate(tx *gorm.DB) error { @@ -81,8 +85,57 @@ func (OrderItem) TableName() string { return "order_items" } +// IsWeighed reports whether this line was sold by weight rather than by count. +func (oi *OrderItem) IsWeighed() bool { + return oi.Weight != nil +} + +// BillableQuantity is the multiplier for unit price and unit cost on this line: +// the weighed amount for weight-based products, the item count otherwise. +// +// This is the ONLY place that decides between the two. Every price or cost +// calculation must go through it — multiplying by Quantity directly would bill a +// 4.2 ons fish as a single ons. +// +// Weighed lines are pinned to quantity = 1 by chk_order_items_weight_single_line, +// so there is no ambiguity between counting and weighing. +func (oi *OrderItem) BillableQuantity() float64 { + if oi.Weight != nil { + return *oi.Weight + } + return float64(oi.Quantity) +} + +// BillableQuantityFor is BillableQuantity for a partial action on this line, such as +// voiding or refunding `units` of it. +// +// Weighed lines are always acted on in full — quantity is 1, so any void or refund +// covers the whole weighing — hence the full weight is returned regardless of units. +func (oi *OrderItem) BillableQuantityFor(units int) float64 { + if oi.Weight != nil { + return *oi.Weight + } + return float64(units) +} + +// RoundMoney rounds to the 2 decimals that every monetary column stores, so the +// value written back always matches what the database keeps. +func RoundMoney(amount float64) float64 { + return math.Round(amount*100) / 100 +} + +// RoundWeight rounds to the 3 decimals of order_items.weight, so a validated value +// is identical to the stored one. +func RoundWeight(weight float64) float64 { + return math.Round(weight*1000) / 1000 +} + func (oi *OrderItem) CalculateTotalPrice() { - oi.TotalPrice = float64(oi.Quantity) * oi.UnitPrice + oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice) +} + +func (oi *OrderItem) CalculateTotalCost() { + oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost) } func (oi *OrderItem) CanBeModified() bool { diff --git a/internal/entities/order_item_test.go b/internal/entities/order_item_test.go new file mode 100644 index 0000000..0292693 --- /dev/null +++ b/internal/entities/order_item_test.go @@ -0,0 +1,104 @@ +package entities + +import "testing" + +func weightPtr(v float64) *float64 { return &v } + +func TestBillableQuantity(t *testing.T) { + tests := []struct { + name string + item OrderItem + want float64 + }{ + {"unit line counts items", OrderItem{Quantity: 3}, 3}, + {"weighed line uses the weight", OrderItem{Quantity: 1, Weight: weightPtr(4.2)}, 4.2}, + {"weight of zero is still a weighed line", OrderItem{Quantity: 1, Weight: weightPtr(0)}, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.item.BillableQuantity(); got != tt.want { + t.Errorf("BillableQuantity() = %v, want %v", got, tt.want) + } + }) + } +} + +// A weighed line is voided or refunded whole, so the units asked for must not shrink +// the amount: refunding "1" of a 4.2 ons line refunds all 4.2 ons. +func TestBillableQuantityForIgnoresUnitsOnWeighedLines(t *testing.T) { + weighed := OrderItem{Quantity: 1, Weight: weightPtr(5.6)} + if got := weighed.BillableQuantityFor(1); got != 5.6 { + t.Errorf("weighed BillableQuantityFor(1) = %v, want 5.6", got) + } + + counted := OrderItem{Quantity: 3} + if got := counted.BillableQuantityFor(2); got != 2 { + t.Errorf("unit BillableQuantityFor(2) = %v, want 2", got) + } +} + +// The regression this whole feature exists to prevent: a 4.2 ons fish priced per ons +// must not be billed as a single ons. +func TestCalculateTotalPriceForWeighedLine(t *testing.T) { + item := OrderItem{Quantity: 1, Weight: weightPtr(4.2), UnitPrice: 4500, UnitCost: 3000} + item.CalculateTotalPrice() + item.CalculateTotalCost() + + if item.TotalPrice != 18900 { + t.Errorf("TotalPrice = %v, want 18900", item.TotalPrice) + } + if item.TotalCost != 12600 { + t.Errorf("TotalCost = %v, want 12600", item.TotalCost) + } +} + +func TestCalculateTotalPriceForUnitLine(t *testing.T) { + item := OrderItem{Quantity: 3, UnitPrice: 25000, UnitCost: 10000} + item.CalculateTotalPrice() + item.CalculateTotalCost() + + if item.TotalPrice != 75000 { + t.Errorf("TotalPrice = %v, want 75000", item.TotalPrice) + } + if item.TotalCost != 30000 { + t.Errorf("TotalCost = %v, want 30000", item.TotalCost) + } +} + +// Sub-rupiah residue must not survive into a decimal(10,2) column. +func TestCalculateTotalPriceRoundsToStoredPrecision(t *testing.T) { + item := OrderItem{Quantity: 1, Weight: weightPtr(4.237), UnitPrice: 4500} + item.CalculateTotalPrice() + + if item.TotalPrice != 19066.5 { + t.Errorf("TotalPrice = %v, want 19066.5", item.TotalPrice) + } +} + +func TestIsWeighed(t *testing.T) { + if (&OrderItem{Quantity: 1}).IsWeighed() { + t.Error("unit line reported as weighed") + } + if !(&OrderItem{Quantity: 1, Weight: weightPtr(1.5)}).IsWeighed() { + t.Error("weighed line reported as unit") + } +} + +func TestRoundWeight(t *testing.T) { + tests := []struct { + in float64 + want float64 + }{ + {4.2, 4.2}, + {4.2374, 4.237}, + {4.2375, 4.238}, + {0.0004, 0}, + } + + for _, tt := range tests { + if got := RoundWeight(tt.in); got != tt.want { + t.Errorf("RoundWeight(%v) = %v, want %v", tt.in, got, tt.want) + } + } +} diff --git a/internal/entities/product.go b/internal/entities/product.go index dc8c524..22d3f6d 100644 --- a/internal/entities/product.go +++ b/internal/entities/product.go @@ -20,6 +20,7 @@ type Product struct { ImageURL *string `gorm:"size:500" json:"image_url"` PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"` UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"` + SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"` HasIngredients bool `gorm:"default:false" json:"has_ingredients"` Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` IsActive bool `gorm:"default:true" json:"is_active"` diff --git a/internal/handler/self_order_handler.go b/internal/handler/self_order_handler.go index ebbfdcb..1d7c576 100644 --- a/internal/handler/self_order_handler.go +++ b/internal/handler/self_order_handler.go @@ -344,6 +344,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) { ProductID: item.ProductID, ProductVariantID: item.ProductVariantID, Quantity: item.Quantity, + Weight: item.Weight, Notes: item.Notes, }) } diff --git a/internal/mappers/order_mapper.go b/internal/mappers/order_mapper.go index b4c84da..5f68ce2 100644 --- a/internal/mappers/order_mapper.go +++ b/internal/mappers/order_mapper.go @@ -115,12 +115,23 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo } } + // Unit is only meaningful for weighed lines, but carry it whenever it was captured. + var unitName, unitAbbreviation *string + if item.Unit != nil { + unitName = &item.Unit.Name + unitAbbreviation = item.Unit.Abbreviation + } + response := &models.OrderItemResponse{ ID: item.ID, OrderID: item.OrderID, ProductID: item.ProductID, ProductVariantID: item.ProductVariantID, Quantity: item.Quantity, + Weight: item.Weight, + UnitID: item.UnitID, + UnitName: unitName, + UnitAbbreviation: unitAbbreviation, UnitPrice: item.UnitPrice, TotalPrice: item.TotalPrice, UnitCost: item.UnitCost, diff --git a/internal/mappers/product_mapper.go b/internal/mappers/product_mapper.go index e91c3ef..57f92ba 100644 --- a/internal/mappers/product_mapper.go +++ b/internal/mappers/product_mapper.go @@ -25,6 +25,7 @@ func ProductEntityToModel(entity *entities.Product) *models.Product { BusinessType: constants.BusinessType(entity.BusinessType), ImageURL: entity.ImageURL, PrinterType: entity.PrinterType, + SellBy: entity.SellBy, Metadata: map[string]interface{}(entity.Metadata), IsActive: entity.IsActive, CreatedAt: entity.CreatedAt, @@ -76,6 +77,11 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr printerType = *req.PrinterType } + sellBy := constants.SellByUnit + if constants.IsValidSellBy(req.SellBy) { + sellBy = req.SellBy + } + metadata := entities.Metadata{} if req.Metadata != nil { metadata = entities.Metadata(req.Metadata) @@ -92,6 +98,8 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr BusinessType: businessType, ImageURL: req.ImageURL, PrinterType: printerType, + UnitID: req.UnitID, + SellBy: sellBy, Metadata: metadata, IsActive: true, // Default to active } @@ -140,6 +148,8 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse { BusinessType: constants.BusinessType(entity.BusinessType), ImageURL: entity.ImageURL, PrinterType: entity.PrinterType, + UnitID: entity.UnitID, + SellBy: entity.SellBy, Metadata: map[string]interface{}(entity.Metadata), IsActive: entity.IsActive, CreatedAt: entity.CreatedAt, @@ -185,6 +195,14 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update entity.PrinterType = *req.PrinterType } + if req.UnitID != nil { + entity.UnitID = req.UnitID + } + + if req.SellBy != nil && constants.IsValidSellBy(*req.SellBy) { + entity.SellBy = *req.SellBy + } + if req.Metadata != nil { if entity.Metadata == nil { entity.Metadata = make(entities.Metadata) diff --git a/internal/models/analytics.go b/internal/models/analytics.go index d3aaa07..109a65b 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -233,6 +233,7 @@ type ProductAnalyticsData struct { CategoryName string `json:"category_name"` CategoryOrder int `json:"category_order"` QuantitySold int64 `json:"quantity_sold"` + WeightSold float64 `json:"weight_sold"` Revenue float64 `json:"revenue"` AveragePrice float64 `json:"average_price"` OrderCount int64 `json:"order_count"` @@ -359,6 +360,7 @@ type ParentCategoryAnalyticsProductData struct { ProductSku string `json:"product_sku"` ProductPrice float64 `json:"product_price"` QuantitySold int64 `json:"quantity_sold"` + WeightSold float64 `json:"weight_sold"` Revenue float64 `json:"revenue"` AveragePrice float64 `json:"average_price"` OrderCount int64 `json:"order_count"` diff --git a/internal/models/order.go b/internal/models/order.go index 313c496..d18e2a9 100644 --- a/internal/models/order.go +++ b/internal/models/order.go @@ -44,6 +44,8 @@ type OrderItem struct { ProductID uuid.UUID ProductVariantID *uuid.UUID Quantity int + Weight *float64 + UnitID *uuid.UUID UnitPrice float64 TotalPrice float64 UnitCost float64 @@ -99,7 +101,8 @@ type CreateOrderItemRequest struct { ProductID uuid.UUID `validate:"required"` ProductVariantID *uuid.UUID `validate:"omitempty"` Quantity int `validate:"required,min=1"` - UnitPrice *float64 `validate:"omitempty,min=0"` // Optional, will use database price if not provided + Weight *float64 `validate:"omitempty,gt=0"` + UnitPrice *float64 `validate:"omitempty,min=0"` // Ignored: price always comes from the database Modifiers []map[string]interface{} `validate:"omitempty"` Notes *string `validate:"omitempty,max=500"` Metadata map[string]interface{} `validate:"omitempty"` @@ -191,6 +194,10 @@ type OrderItemResponse struct { CategoryID *uuid.UUID CategoryName *string Quantity int + Weight *float64 + UnitID *uuid.UUID + UnitName *string + UnitAbbreviation *string UnitPrice float64 TotalPrice float64 UnitCost float64 diff --git a/internal/models/product.go b/internal/models/product.go index 0e0b6a1..1d2142b 100644 --- a/internal/models/product.go +++ b/internal/models/product.go @@ -19,6 +19,7 @@ type Product struct { BusinessType constants.BusinessType ImageURL *string PrinterType string + SellBy string UnitID *uuid.UUID HasIngredients bool Metadata map[string]interface{} @@ -52,6 +53,7 @@ type CreateProductRequest struct { PrinterType *string `validate:"omitempty,max=50"` PrintToChecker *bool `validate:"omitempty"` UnitID *uuid.UUID `validate:"omitempty"` + SellBy string `validate:"omitempty,oneof=unit weight"` HasIngredients bool `validate:"omitempty"` Metadata map[string]interface{} Variants []CreateProductVariantRequest `validate:"omitempty,dive"` @@ -73,6 +75,7 @@ type UpdateProductRequest struct { PrinterType *string `validate:"omitempty,max=50"` PrintToChecker *bool `validate:"omitempty"` UnitID *uuid.UUID `validate:"omitempty"` + SellBy *string `validate:"omitempty,oneof=unit weight"` HasIngredients *bool `validate:"omitempty"` Metadata map[string]interface{} IsActive *bool @@ -110,6 +113,7 @@ type ProductResponse struct { BusinessType constants.BusinessType ImageURL *string PrinterType string + SellBy string PrintToChecker bool UnitID *uuid.UUID HasIngredients bool diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 37ad289..1297e90 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -322,6 +322,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m CategoryName: data.CategoryName, CategoryOrder: data.CategoryOrder, QuantitySold: data.QuantitySold, + WeightSold: data.WeightSold, Revenue: data.Revenue, AveragePrice: data.AveragePrice, OrderCount: data.OrderCount, @@ -447,6 +448,7 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co ProductSku: product.ProductSku, ProductPrice: product.ProductPrice, QuantitySold: product.QuantitySold, + WeightSold: product.WeightSold, Revenue: product.Revenue, AveragePrice: product.AveragePrice, OrderCount: product.OrderCount, diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 6065005..3e14e23 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" @@ -149,6 +150,33 @@ func NewOrderProcessorImpl( } } +// resolveLineQuantity reconciles the requested quantity and weight against how the +// product is sold, returning the values to store on the order line. +// +// For weight-based products a line is exactly one weighing, so quantity is pinned to 1 +// and the weighed amount is what gets priced. This mirrors +// chk_order_items_weight_single_line in the database. +func resolveLineQuantity(product *entities.Product, quantity int, weight *float64) (int, *float64, error) { + if product.SellBy == constants.SellByWeight { + if weight == nil { + return 0, nil, fmt.Errorf("product %s is sold by weight and requires a weight", product.Name) + } + rounded := entities.RoundWeight(*weight) + if rounded <= 0 { + return 0, nil, fmt.Errorf("weight for product %s must be greater than 0", product.Name) + } + return 1, &rounded, nil + } + + if weight != nil { + return 0, nil, fmt.Errorf("product %s is not sold by weight and must not carry a weight", product.Name) + } + if quantity < 1 { + return 0, nil, fmt.Errorf("quantity for product %s must be at least 1", product.Name) + } + return quantity, nil, nil +} + func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) { orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID) if err != nil { @@ -194,25 +222,29 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create } } - itemTotalPrice := float64(itemReq.Quantity) * unitPrice - itemTotalCost := float64(itemReq.Quantity) * unitCost - - subtotal += itemTotalPrice - totalCost += itemTotalCost + quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight) + if err != nil { + return nil, err + } orderItem := &entities.OrderItem{ ProductID: itemReq.ProductID, ProductVariantID: itemReq.ProductVariantID, - Quantity: itemReq.Quantity, + Quantity: quantity, + Weight: weight, + UnitID: product.UnitID, UnitPrice: unitPrice, - TotalPrice: itemTotalPrice, UnitCost: unitCost, - TotalCost: itemTotalCost, Modifiers: entities.Modifiers(itemReq.Modifiers), Notes: itemReq.Notes, Metadata: entities.Metadata(itemReq.Metadata), Status: entities.OrderItemStatusPending, } + orderItem.CalculateTotalPrice() + orderItem.CalculateTotalCost() + + subtotal += orderItem.TotalPrice + totalCost += orderItem.TotalCost orderItems = append(orderItems, orderItem) } @@ -327,26 +359,30 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, } } - itemTotalPrice := float64(itemReq.Quantity) * unitPrice - itemTotalCost := float64(itemReq.Quantity) * unitCost - - newSubtotal += itemTotalPrice - newTotalCost += itemTotalCost + quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight) + if err != nil { + return nil, err + } orderItem := &entities.OrderItem{ OrderID: orderID, ProductID: itemReq.ProductID, ProductVariantID: itemReq.ProductVariantID, - Quantity: itemReq.Quantity, + Quantity: quantity, + Weight: weight, + UnitID: product.UnitID, UnitPrice: unitPrice, - TotalPrice: itemTotalPrice, UnitCost: unitCost, - TotalCost: itemTotalCost, Modifiers: entities.Modifiers(itemReq.Modifiers), Notes: itemReq.Notes, Metadata: entities.Metadata(itemReq.Metadata), Status: entities.OrderItemStatusPending, } + orderItem.CalculateTotalPrice() + orderItem.CalculateTotalCost() + + newSubtotal += orderItem.TotalPrice + newTotalCost += orderItem.TotalCost addedOrderItems = append(addedOrderItems, orderItem) } @@ -602,8 +638,9 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID) } - voidedAmount := float64(itemVoid.Quantity) * orderItem.UnitPrice - voidedCost := float64(itemVoid.Quantity) * orderItem.UnitCost + billable := orderItem.BillableQuantityFor(itemVoid.Quantity) + voidedAmount := entities.RoundMoney(billable * orderItem.UnitPrice) + voidedCost := entities.RoundMoney(billable * orderItem.UnitCost) totalVoidedAmount += voidedAmount totalVoidedCost += voidedCost @@ -720,7 +757,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req return fmt.Errorf("refund quantity cannot exceed original quantity") } - refundAmount := float64(refundQuantity) * orderItem.UnitPrice + refundAmount := entities.RoundMoney(orderItem.BillableQuantityFor(refundQuantity) * orderItem.UnitPrice) if itemRefund.RefundAmount != nil { refundAmount = *itemRefund.RefundAmount } @@ -1016,7 +1053,7 @@ func (p *OrderProcessorImpl) createInventoryMovement(ctx context.Context, item * PreviousQuantity: float64(updatedInventory.Quantity + item.Quantity), // Add back the quantity that was subtracted NewQuantity: float64(updatedInventory.Quantity), UnitCost: item.UnitCost, - TotalCost: float64(item.Quantity) * item.UnitCost, + TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost), ReferenceType: func() *entities.InventoryMovementReferenceType { t := entities.InventoryMovementReferenceTypePayment return &t @@ -1314,7 +1351,7 @@ func (p *OrderProcessorImpl) prepareProductInventoryMovement(item *entities.Orde PreviousQuantity: float64(previousQuantity), NewQuantity: float64(updatedInventory.Quantity), UnitCost: item.UnitCost, - TotalCost: float64(item.Quantity) * item.UnitCost, + TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost), ReferenceType: func() *entities.InventoryMovementReferenceType { t := entities.InventoryMovementReferenceTypePayment return &t @@ -1380,7 +1417,7 @@ type ingredientRecipeItem struct { // prepareIngredientRecipeItem prepares data for a single ingredient recipe without making database calls func (p *OrderProcessorImpl) prepareIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment) (*ingredientRecipeItem, error) { - totalIngredientQuantity := recipe.Quantity * float64(item.Quantity) + totalIngredientQuantity := recipe.Quantity * item.BillableQuantity() currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID) if err != nil { @@ -1478,7 +1515,7 @@ func (p *OrderProcessorImpl) prepareRefundProductInventoryMovement(item *entitie PreviousQuantity: float64(previousQuantity), NewQuantity: float64(updatedInventory.Quantity), UnitCost: item.UnitCost, - TotalCost: float64(refundedQuantity) * item.UnitCost, + TotalCost: entities.RoundMoney(item.BillableQuantityFor(refundedQuantity) * item.UnitCost), ReferenceType: func() *entities.InventoryMovementReferenceType { t := entities.InventoryMovementReferenceTypeRefund return &t @@ -1544,7 +1581,7 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeData(ctx context.Con // prepareRefundedIngredientRecipeItem prepares data for a single ingredient recipe restoration func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, refundRatio float64, reason string, refundedBy uuid.UUID, refundAmount float64) (*ingredientRecipeItem, error) { // Calculate total ingredient quantity needed based on order item quantity - totalIngredientQuantity := recipe.Quantity * float64(item.Quantity) + totalIngredientQuantity := recipe.Quantity * item.BillableQuantity() // Get current ingredient details currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID) diff --git a/internal/processor/order_weight_test.go b/internal/processor/order_weight_test.go new file mode 100644 index 0000000..1558707 --- /dev/null +++ b/internal/processor/order_weight_test.go @@ -0,0 +1,106 @@ +package processor + +import ( + "testing" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" +) + +func fPtr(v float64) *float64 { return &v } + +func TestResolveLineQuantity(t *testing.T) { + weightProduct := &entities.Product{Name: "Ikan Tude", SellBy: constants.SellByWeight} + unitProduct := &entities.Product{Name: "Nasi Goreng", SellBy: constants.SellByUnit} + + tests := []struct { + name string + product *entities.Product + quantity int + weight *float64 + wantQuantity int + wantWeight *float64 + wantErr bool + }{ + { + name: "weighed line is pinned to quantity 1", + // Mirrors chk_order_items_weight_single_line: even if a client sends 3, + // one weighing is one line. + product: weightProduct, quantity: 3, weight: fPtr(4.2), + wantQuantity: 1, wantWeight: fPtr(4.2), + }, + { + name: "weight is rounded to stored precision", + product: weightProduct, quantity: 1, weight: fPtr(5.6004), + wantQuantity: 1, wantWeight: fPtr(5.6), + }, + { + name: "weight product without a weight is rejected", + product: weightProduct, quantity: 1, weight: nil, + wantErr: true, + }, + { + name: "weight that rounds away to zero is rejected", + product: weightProduct, quantity: 1, weight: fPtr(0.0004), + wantErr: true, + }, + { + name: "unit line keeps its quantity and carries no weight", + product: unitProduct, quantity: 3, weight: nil, + wantQuantity: 3, wantWeight: nil, + }, + { + name: "weight on a unit product is rejected", + // Otherwise the line would silently be priced by weight. + product: unitProduct, quantity: 1, weight: fPtr(4.2), + wantErr: true, + }, + { + name: "non-positive quantity on a unit product is rejected", + product: unitProduct, quantity: 0, weight: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotQty, gotWeight, err := resolveLineQuantity(tt.product, tt.quantity, tt.weight) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected an error, got quantity=%v weight=%v", gotQty, gotWeight) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotQty != tt.wantQuantity { + t.Errorf("quantity = %v, want %v", gotQty, tt.wantQuantity) + } + switch { + case tt.wantWeight == nil && gotWeight != nil: + t.Errorf("weight = %v, want nil", *gotWeight) + case tt.wantWeight != nil && gotWeight == nil: + t.Errorf("weight = nil, want %v", *tt.wantWeight) + case tt.wantWeight != nil && *gotWeight != *tt.wantWeight: + t.Errorf("weight = %v, want %v", *gotWeight, *tt.wantWeight) + } + }) + } +} + +// A product created before this feature has sell_by defaulted to 'unit' by the +// migration, but a zero-valued struct must behave the same way. +func TestResolveLineQuantityTreatsEmptySellByAsUnit(t *testing.T) { + legacy := &entities.Product{Name: "Legacy"} + + quantity, weight, err := resolveLineQuantity(legacy, 2, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if quantity != 2 || weight != nil { + t.Errorf("got quantity=%v weight=%v, want quantity=2 weight=nil", quantity, weight) + } +} diff --git a/internal/processor/split_bill_processor.go b/internal/processor/split_bill_processor.go index 5559125..d527a89 100644 --- a/internal/processor/split_bill_processor.go +++ b/internal/processor/split_bill_processor.go @@ -140,7 +140,7 @@ func calculateSplitAmounts(req *models.SplitBillRequest, validation *SplitBillVa for _, item := range req.Items { orderItem := validation.OrderItems[item.OrderItemID] - itemAmount := float64(item.Quantity) * orderItem.UnitPrice + itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice) itemTaxAmount := itemAmount * validation.Outlet.TaxRate totalItemAmount := itemAmount + itemTaxAmount @@ -186,7 +186,7 @@ func (p *SplitBillProcessorImpl) createSplitPayment(ctx context.Context, req *mo func (p *SplitBillProcessorImpl) createPaymentOrderItems(ctx context.Context, splitPayment *entities.Payment, req *models.SplitBillRequest, validation *SplitBillValidation) error { for _, item := range req.Items { orderItem := validation.OrderItems[item.OrderItemID] - itemAmount := float64(item.Quantity) * orderItem.UnitPrice + itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice) itemTaxAmount := itemAmount * validation.Outlet.TaxRate totalItemAmount := itemAmount + itemTaxAmount @@ -228,7 +228,7 @@ func (p *SplitBillProcessorImpl) updateOrderAfterSplit(ctx context.Context, orde quantityInfo[item.OrderItemID.String()] = map[string]interface{}{ "quantity": item.Quantity, "unit_price": orderItem.UnitPrice, - "total_amount": float64(item.Quantity) * orderItem.UnitPrice, + "total_amount": entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice), } } order.Metadata[MetadataKeyLastSplitQuantities] = quantityInfo diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 9d83d16..fe23ebc 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -461,6 +461,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ c.name as category_name, c.order as category_order, COALESCE(SUM(oi.quantity), 0) as quantity_sold, + COALESCE(SUM(oi.weight), 0) as weight_sold, COALESCE(SUM(oi.total_price), 0) as revenue, CASE WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(oi.quantity) @@ -1167,9 +1168,9 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.C } type purchasingTotals struct { - Total float64 - RawMaterial float64 - Expense float64 + Total float64 + RawMaterial float64 + Expense float64 } func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) { diff --git a/internal/repository/order_item_repository.go b/internal/repository/order_item_repository.go index d983b6b..6f31acc 100644 --- a/internal/repository/order_item_repository.go +++ b/internal/repository/order_item_repository.go @@ -50,6 +50,7 @@ func (r *OrderItemRepositoryImpl) GetByOrderID(ctx context.Context, orderID uuid err := r.db.WithContext(ctx). Preload("Product"). Preload("ProductVariant"). + Preload("Unit"). Where("order_id = ?", orderID). Find(&orderItems).Error return orderItems, err @@ -109,8 +110,12 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI return err } - if voidQuantity >= orderItem.Quantity { - voidedAmount := float64(voidQuantity) * orderItem.UnitPrice + // A weighed line is one weighing, so it is voided whole — never split into a + // remainder row. chk_order_items_weight_single_line pins its quantity to 1, which + // already sends it down this branch; the explicit check keeps that guarantee + // visible and independent of the comparison below. + if orderItem.IsWeighed() || voidQuantity >= orderItem.Quantity { + voidedAmount := entities.RoundMoney(orderItem.BillableQuantityFor(voidQuantity) * orderItem.UnitPrice) updates := map[string]interface{}{ "refund_quantity": voidQuantity, @@ -128,16 +133,18 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI Updates(updates).Error } + // Only unit-priced lines reach here: weighed lines were voided whole above. voidedOrderItem := entities.OrderItem{ OrderID: orderItem.OrderID, ProductID: orderItem.ProductID, ProductVariantID: orderItem.ProductVariantID, Quantity: voidQuantity, + UnitID: orderItem.UnitID, UnitPrice: orderItem.UnitPrice, - TotalPrice: float64(voidQuantity) * orderItem.UnitPrice, + TotalPrice: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice), UnitCost: orderItem.UnitCost, - TotalCost: float64(voidQuantity) * orderItem.UnitCost, - RefundAmount: float64(voidQuantity) * orderItem.UnitPrice, + TotalCost: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitCost), + RefundAmount: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice), RefundQuantity: voidQuantity, IsPartiallyRefunded: false, IsFullyRefunded: true, @@ -155,8 +162,8 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI } remainingQuantity := orderItem.Quantity - voidQuantity - remainingTotalPrice := float64(remainingQuantity) * orderItem.UnitPrice - remainingTotalCost := float64(remainingQuantity) * orderItem.UnitCost + remainingTotalPrice := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitPrice) + remainingTotalCost := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitCost) updates := map[string]interface{}{ "quantity": remainingQuantity, diff --git a/internal/repository/order_repository.go b/internal/repository/order_repository.go index 9d13a48..341ce14 100644 --- a/internal/repository/order_repository.go +++ b/internal/repository/order_repository.go @@ -63,6 +63,7 @@ func (r *OrderRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID Preload("OrderItems.Product.Category"). Preload("OrderItems.Product.ProductOutletPrices"). Preload("OrderItems.ProductVariant"). + Preload("OrderItems.Unit"). Preload("Payments"). Preload("Payments.PaymentMethod"). Preload("Payments.PaymentOrderItems"). @@ -144,6 +145,7 @@ func (r *OrderRepositoryImpl) List(ctx context.Context, filters map[string]inter Preload("OrderItems.Product.Category"). Preload("OrderItems.Product.ProductOutletPrices"). Preload("OrderItems.ProductVariant"). + Preload("OrderItems.Unit"). Preload("Payments"). Preload("Payments.PaymentMethod"). Preload("Payments.PaymentOrderItems"). @@ -162,6 +164,7 @@ func (r *OrderRepositoryImpl) ListBySessionID(ctx context.Context, sessionID str Preload("OrderItems.Product.Category"). Preload("OrderItems.Product.ProductOutletPrices"). Preload("OrderItems.ProductVariant"). + Preload("OrderItems.Unit"). Preload("Payments"). Preload("Payments.PaymentMethod"). Preload("Payments.PaymentOrderItems"). diff --git a/internal/service/report_service.go b/internal/service/report_service.go index 7c113ff..8ba124f 100644 --- a/internal/service/report_service.go +++ b/internal/service/report_service.go @@ -64,6 +64,7 @@ type reportSummary struct { type reportItem struct { Name string Quantity int64 + Weight string GrossSales string Discount string NetSales string @@ -161,6 +162,7 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org items = append(items, reportItem{ Name: p.ProductName, Quantity: p.QuantitySold, + Weight: formatWeight(p.WeightSold), GrossSales: formatCurrency(p.Revenue), Discount: formatCurrency(0), NetSales: formatCurrency(p.Revenue), @@ -489,6 +491,16 @@ func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLoss return result } +// formatWeight renders the weight sold for a product, or a dash when the product is +// not sold by weight. Trailing zeros are trimmed so 4.200 prints as "4,2". +func formatWeight(weight float64) string { + if weight <= 0 { + return "-" + } + s := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.3f", weight), "0"), ".") + return strings.Replace(s, ".", ",", 1) +} + func formatCurrencyOrDash(amount float64) string { if amount == 0 { return "-" diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index d9214eb..8590c18 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -291,6 +291,7 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con CategoryName: item.CategoryName, CategoryOrder: item.CategoryOrder, QuantitySold: item.QuantitySold, + WeightSold: item.WeightSold, Revenue: item.Revenue, AveragePrice: item.AveragePrice, OrderCount: item.OrderCount, @@ -506,6 +507,7 @@ func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAna ProductSku: product.ProductSku, ProductPrice: product.ProductPrice, QuantitySold: product.QuantitySold, + WeightSold: product.WeightSold, Revenue: product.Revenue, AveragePrice: product.AveragePrice, OrderCount: product.OrderCount, @@ -592,6 +594,7 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse) CategoryID: item.CategoryID, CategoryName: item.CategoryName, QuantitySold: item.QuantitySold, + WeightSold: item.WeightSold, Revenue: item.Revenue, AveragePrice: item.AveragePrice, OrderCount: item.OrderCount, diff --git a/internal/transformer/order_transformer.go b/internal/transformer/order_transformer.go index 88f88d6..92b8ab8 100644 --- a/internal/transformer/order_transformer.go +++ b/internal/transformer/order_transformer.go @@ -103,6 +103,10 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse { CategoryID: item.CategoryID, CategoryName: item.CategoryName, Quantity: item.Quantity, + Weight: item.Weight, + UnitID: item.UnitID, + UnitName: item.UnitName, + UnitAbbreviation: item.UnitAbbreviation, UnitPrice: item.UnitPrice, TotalPrice: item.TotalPrice, Modifiers: item.Modifiers, @@ -174,6 +178,10 @@ func AddToOrderModelToContract(resp *models.AddToOrderResponse) *contract.AddToO CategoryID: item.CategoryID, CategoryName: item.CategoryName, Quantity: item.Quantity, + Weight: item.Weight, + UnitID: item.UnitID, + UnitName: item.UnitName, + UnitAbbreviation: item.UnitAbbreviation, UnitPrice: item.UnitPrice, TotalPrice: item.TotalPrice, Modifiers: item.Modifiers, diff --git a/internal/transformer/product_transformer.go b/internal/transformer/product_transformer.go index e6f0321..0a628f6 100644 --- a/internal/transformer/product_transformer.go +++ b/internal/transformer/product_transformer.go @@ -20,6 +20,11 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr businessType = constants.BusinessType(*req.BusinessType) } + sellBy := constants.SellByUnit + if req.SellBy != nil { + sellBy = *req.SellBy + } + var variants []models.CreateProductVariantRequest if req.Variants != nil { variants = make([]models.CreateProductVariantRequest, len(req.Variants)) @@ -58,6 +63,8 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr ImageURL: req.ImageURL, PrinterType: req.PrinterType, PrintToChecker: req.PrintToChecker, + UnitID: req.UnitID, + SellBy: sellBy, Metadata: metadata, Variants: variants, } @@ -86,6 +93,8 @@ func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Up ImageURL: req.ImageURL, PrinterType: req.PrinterType, PrintToChecker: req.PrintToChecker, + UnitID: req.UnitID, + SellBy: req.SellBy, Metadata: metadata, IsActive: req.IsActive, } @@ -145,6 +154,8 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod ImageURL: prod.ImageURL, PrinterType: prod.PrinterType, PrintToChecker: prod.PrintToChecker, + UnitID: prod.UnitID, + SellBy: prod.SellBy, Metadata: prod.Metadata, IsActive: prod.IsActive, CreatedAt: prod.CreatedAt, diff --git a/migrations/000089_add_weight_to_order_items.down.sql b/migrations/000089_add_weight_to_order_items.down.sql new file mode 100644 index 0000000..50803d6 --- /dev/null +++ b/migrations/000089_add_weight_to_order_items.down.sql @@ -0,0 +1,15 @@ +DROP INDEX IF EXISTS idx_order_items_unit_id; + +ALTER TABLE order_items + DROP CONSTRAINT IF EXISTS chk_order_items_weight_single_line, + DROP CONSTRAINT IF EXISTS chk_order_items_weight_positive; + +ALTER TABLE order_items + DROP COLUMN IF EXISTS unit_id, + DROP COLUMN IF EXISTS weight; + +ALTER TABLE products + DROP CONSTRAINT IF EXISTS chk_products_sell_by; + +ALTER TABLE products + DROP COLUMN IF EXISTS sell_by; diff --git a/migrations/000089_add_weight_to_order_items.up.sql b/migrations/000089_add_weight_to_order_items.up.sql new file mode 100644 index 0000000..00157a2 --- /dev/null +++ b/migrations/000089_add_weight_to_order_items.up.sql @@ -0,0 +1,30 @@ +-- Weight-based products (e.g. fish sold per ons/kg). +-- One weighing = one order_items row: quantity stays 1, the weight goes in `weight`. + +ALTER TABLE products + ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit'; + +ALTER TABLE products + ADD CONSTRAINT chk_products_sell_by + CHECK (sell_by IN ('unit', 'weight')); + +ALTER TABLE order_items + ADD COLUMN weight DECIMAL(12,3), + ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT; + +-- A weighed line always carries a positive weight... +ALTER TABLE order_items + ADD CONSTRAINT chk_order_items_weight_positive + CHECK (weight IS NULL OR weight > 0); + +-- ...and always represents exactly one weighing, so its quantity is pinned to 1. +-- This is what makes billable quantity unambiguous and keeps void all-or-nothing. +ALTER TABLE order_items + ADD CONSTRAINT chk_order_items_weight_single_line + CHECK (weight IS NULL OR quantity = 1); + +CREATE INDEX idx_order_items_unit_id ON order_items(unit_id); + +COMMENT ON COLUMN products.sell_by IS 'How the product is sold: unit (discrete count) or weight (weighed per transaction)'; +COMMENT ON COLUMN order_items.weight IS 'Weighed amount in unit_id units; NULL for unit-priced products. Price is weight * unit_price.'; +COMMENT ON COLUMN order_items.unit_id IS 'Snapshot of the product unit at sale time, so historical lines keep their meaning'; diff --git a/templates/daily_transaction.html b/templates/daily_transaction.html index b9e788e..ed43d76 100644 --- a/templates/daily_transaction.html +++ b/templates/daily_transaction.html @@ -524,6 +524,7 @@ No. Nama Item Qty + Berat Penjualan Kotor Diskon Penjualan Bersih @@ -537,6 +538,7 @@ {{add $i 1}} {{$item.Name}} {{$item.Quantity}} + {{$item.Weight}} {{$item.GrossSales}} {{$item.Discount}} {{$item.NetSales}} -- 2.54.0 From d3987c711442a1c647b35a33f8daa368ac8cef77 Mon Sep 17 00:00:00 2001 From: efrilm Date: Sun, 6 Sep 2026 17:20:22 +0700 Subject: [PATCH 07/47] docs(order): add weight-based product integration guide Client-facing companion to the RFC, aimed at the POS Mobile and Backoffice teams: endpoints and payloads for setting up a weight product, placing an order, rendering the line, and voiding, refunding or splitting it. Documents two gaps the teams have to work around rather than discover: unit_id is not yet enforced when sell_by is "weight", so Backoffice must require it in the form; and money rounds to 2 decimals rather than whole rupiah, which is still an open decision. Co-Authored-By: Claude Opus 5 (1M context) --- docs/integration-weight-based-products.md | 269 ++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/integration-weight-based-products.md diff --git a/docs/integration-weight-based-products.md b/docs/integration-weight-based-products.md new file mode 100644 index 0000000..61b5b16 --- /dev/null +++ b/docs/integration-weight-based-products.md @@ -0,0 +1,269 @@ +# Integrasi Produk Timbangan — POS Mobile & Backoffice + +**Migrasi:** `000089` · **Base URL:** `/api/v1` · **Kompatibilitas:** mundur penuh + +Panduan untuk menjual produk per timbangan (ikan, daging, buah) dari sisi klien. +Alasan di balik setiap keputusan desain ada di [`rfc-weight-based-products.md`](./rfc-weight-based-products.md). + +--- + +## 1. Konsep inti + +**Satu penimbangan = satu baris order.** + +Pelanggan memesan Ikan Tude 4,2 ons, lalu memesan Ikan Tude lagi 5,6 ons. Itu **dua +baris terpisah**, karena keduanya dua ikan berbeda yang ditimbang sendiri-sendiri. + +| Bukan begini | Melainkan begini | +|---|---| +| `quantity: 9.8` | `quantity: 1, weight: 4.2`
`quantity: 1, weight: 5.6` | +| Dua ikan hilang jejaknya, dan `quantity` bertipe integer sehingga pecahan ditolak | Tiap penimbangan berdiri sendiri, bisa di-void atau dibayar terpisah | + +Empat aturan yang berlaku di seluruh dokumen ini: + +1. `quantity` untuk produk timbangan **selalu 1**. Backend memaksanya, dan database + menolak nilai lain lewat constraint `chk_order_items_weight_single_line`. +2. `weight` menyimpan angka timbangan, dalam satuan produk itu sendiri (ons, kg, gram — + apa pun yang dipilih saat setup). +3. Harga baris dihitung `weight × unit_price`, bukan `quantity × unit_price`. + `unit_price` tetap berarti harga per satu satuan (per ons). +4. **Jangan pernah menggabungkan dua baris** produk timbangan menjadi satu, meski + produknya sama. + +Untuk produk biasa tidak ada yang berubah: `weight` tidak dikirim, `quantity` tetap +cacah seperti sekarang. + +--- + +## 2. Backoffice — setup produk + +### 2.1 Pastikan satuannya ada + +Satuan disimpan per organisasi. Buat sekali, pakai ulang untuk semua produk timbangan. + +`POST /api/v1/units` + +```json +{ + "name": "Ons", + "abbreviation": "ons", + "is_active": true +} +``` + +`abbreviation` yang dipakai POS untuk mencetak `4,2 ons` di struk — isi dengan bentuk +pendek yang benar-benar ingin ditampilkan. Daftar satuan dibaca lewat `GET /api/v1/units`. + +### 2.2 Buat produk sebagai produk timbangan + +`POST /api/v1/products` + +```json +{ + "category_id": "…", + "name": "Ikan Tude", + "price": 4500, + "cost": 3000, + "sell_by": "weight", + "unit_id": "" +} +``` + +| Field | Tipe | Keterangan | +|---|---|---| +| `sell_by` | `"unit"` \| `"weight"` | Opsional, default `"unit"`. Nilai lain ditolak validator. | +| `unit_id` | UUID | Opsional di backend, tapi **wajib secara praktik** untuk `sell_by: "weight"` — lihat §8. | +| `price` | number | Harga per satu satuan. Rp 4.500 per ons, bukan harga per ikan. | + +Keduanya juga bisa diubah lewat `PUT /api/v1/products/:id` dengan bentuk yang sama, dan +ikut terbaca di setiap response produk (`GET /api/v1/products`, `/products/all`, +`/products/:id`). + +### 2.3 Catatan UI + +- Kunci `sell_by` **setelah produk punya transaksi**. Mengubah produk lama dari `unit` + ke `weight` tidak mengubah baris order yang sudah ada — baris lama tetap dihitung per + cacah — tapi akan membingungkan pengguna yang melihat riwayatnya. +- Saat `weight` dipilih, jadikan pemilih satuan sebagai field **wajib** di form. +- Ubah label harga mengikuti satuan yang dipilih: *"Harga per ons"*. + +--- + +## 3. POS Mobile — transaksi + +### 3.1 Bentuk input mengikuti `sell_by` + +| `sell_by` | Input di POS | Yang dikirim | +|---|---|---| +| `"unit"` | Stepper − / + seperti sekarang | `quantity: n`, tanpa `weight` | +| `"weight"` | Papan angka desimal, satuan dari `unit` produk | `quantity: 1` + `weight: 4.2` | + +### 3.2 Mengirim order + +`POST /api/v1/orders` + +```json +{ + "outlet_id": "…", + "user_id": "…", + "order_type": "dine_in", + "order_items": [ + { "product_id": "", "quantity": 1, "weight": 4.2 }, + { "product_id": "", "quantity": 1, "weight": 5.6 }, + { "product_id": "", "quantity": 2 } + ] +} +``` + +Bentuk yang sama berlaku untuk `POST /api/v1/orders/:id/add-items` dan untuk pemesanan +mandiri `POST /api/v1/self-order/orders`. + +**Presisi.** Berat dibulatkan backend ke 3 desimal. Kirim `4.2` atau `4.237`; angka di +bawah `0.001` membulat ke nol dan ditolak. + +**Harga.** Field `unit_price` pada request **diabaikan** — harga selalu diambil backend +dari master produk. Jangan mengirim harga hasil hitungan sendiri. + +--- + +## 4. Menampilkan baris + +Setiap `order_items[]` di response membawa empat field tambahan: + +```json +{ + "product_name": "Ikan Tude", + "quantity": 1, + "weight": 4.2, + "unit_id": "…", + "unit_name": "Ons", + "unit_abbreviation": "ons", + "unit_price": 4500, + "total_price": 18900 +} +``` + +Semuanya `null` atau absen untuk produk biasa, jadi cabangkan tampilan pada `weight`: + +| Kondisi | Tampilkan | +|---|---| +| `weight == null` | `2 × Rp 25.000` | +| `weight != null` | `4,2 ons × Rp 4.500` | + +**Jangan menampilkan `quantity` untuk baris berbobot** — nilainya selalu 1 dan akan +terbaca seperti "satu ons". Gunakan `weight` dengan `unit_abbreviation`, dan pakai koma +desimal sesuai format Indonesia. + +--- + +## 5. Void, refund, split bill + +Baris berbobot bersifat **utuh**: dibatalkan seluruhnya atau tidak sama sekali. Karena +`quantity`-nya 1, semua endpoint cukup dikirimi `1`, dan backend menghitung nilai +rupiahnya dari `weight`. + +| Aksi | Endpoint | Field untuk baris berbobot | +|---|---|---| +| Void per item | `POST /orders/void` | `items[].quantity: 1` | +| Refund per item | `POST /orders/:id/refund` | `order_items[].refund_quantity: 1` (atau kosongkan) | +| Split bill per item | `POST /orders/split-bill` | `items[].quantity: 1` = bayar baris itu penuh | + +```json +{ + "order_id": "…", + "reason": "Salah timbang", + "type": "ITEM", + "items": [ + { "order_item_id": "", "quantity": 1 } + ] +} +``` + +Untuk split bill, baris berbobot hanya bisa berstatus belum dibayar atau lunas — tidak +ada nilai di antaranya. Sembunyikan stepper jumlah pada baris berbobot, ganti dengan +tombol pilih baris. + +**Batasan yang disengaja.** Mengembalikan *sebagian berat* — 1 ons dari baris 4,2 ons — +tidak didukung. Koreksi salah timbang ditangani dengan void baris itu lalu input ulang, +sehingga jejak auditnya tetap jujur. + +--- + +## 6. Referensi error + +Semua error mengikuti amplop standar. Pesan validasi baru muncul dengan kode `900`: + +```json +{ + "success": false, + "data": null, + "errors": [ + { "code": "900", "entity": "ORDER", + "cause": "product Ikan Tude is sold by weight and requires a weight" } + ] +} +``` + +| Pesan (`cause`) | Penyebab | Perbaikan di klien | +|---|---|---| +| `… is sold by weight and requires a weight` | Produk `sell_by: "weight"` dikirim tanpa `weight` | Wajibkan input timbangan sebelum item masuk keranjang | +| `… is not sold by weight and must not carry a weight` | `weight` dikirim untuk produk satuan | Kirim `weight` hanya bila `sell_by == "weight"` | +| `weight for … must be greater than 0` | Berat nol, negatif, atau membulat ke nol | Validasi minimal `0.001` di keypad | +| `quantity for … must be at least 1` | Produk satuan dengan `quantity` ≤ 0 | Perilaku lama, tidak berubah | + +Pesan menyebut **nama produk**, sehingga bisa ditampilkan apa adanya ke kasir. + +--- + +## 7. Kompatibilitas mundur + +- Semua produk lama otomatis `sell_by: "unit"`. Perilakunya identik dengan sebelumnya. +- `weight` opsional di request. Klien yang tidak mengenalnya tetap berfungsi penuh untuk + produk satuan. +- Field baru di response semuanya `omitempty` — tidak muncul untuk baris biasa, jadi + parser lama tidak terganggu. +- `quantity` tetap **integer** di seluruh API. Tidak ada field yang berubah tipe. + +Yang tidak berfungsi di klien lama hanyalah menjual produk timbangan itu sendiri — +permintaannya ditolak dengan pesan jelas, bukan gagal diam-diam. + +--- + +## 8. Batasan yang diketahui + +> **Perlu ditangani di frontend.** Backend **belum** memaksa `unit_id` terisi saat +> `sell_by: "weight"`. Produk timbangan tanpa satuan akan tersimpan, tapi baris ordernya +> keluar dengan `unit_abbreviation: null` — struk tidak bisa mencetak "ons". Sampai +> validasi itu ditambahkan di backend, **Backoffice wajib mewajibkannya di form**. + +- **Pembulatan uang ke 2 desimal.** `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`, + bukan dibulatkan ke rupiah utuh. Bila kasir harus menerima rupiah penuh, ini perlu + diputuskan dan diubah di backend lebih dulu (`RoundMoney`, satu tempat). +- **Stok belum otomatis berkurang** saat penjualan — untuk produk timbangan maupun + produk biasa. Pengurangan stok belum tersambung di backend, jadi jangan menampilkan + sisa stok yang mengandaikan itu berjalan. + +--- + +## 9. Checklist per tim + +**Backoffice Website** + +- [ ] CRUD satuan tersedia di menu master data +- [ ] Form produk punya pilihan cara jual: Satuan / Timbangan +- [ ] Pemilih satuan menjadi wajib saat Timbangan dipilih +- [ ] Label harga ikut satuan — "Harga per ons" +- [ ] `sell_by` dikunci untuk produk yang sudah bertransaksi +- [ ] Daftar produk menandai mana yang dijual per timbangan +- [ ] Laporan harian menampilkan kolom Berat + +**POS Mobile** + +- [ ] Menu membaca `sell_by` tiap produk +- [ ] Papan angka desimal untuk produk timbangan +- [ ] Kirim `quantity: 1` + `weight` +- [ ] Dua penimbangan menjadi dua baris, tidak digabung +- [ ] Keranjang & struk menampilkan `4,2 ons × Rp 4.500` +- [ ] Void & refund baris berbobot bersifat utuh +- [ ] Split bill: pilih baris, bukan stepper jumlah +- [ ] Pesan error validasi ditampilkan ke kasir -- 2.54.0 From ebf666c004f197866e12058841b3176a89d4e1cd Mon Sep 17 00:00:00 2001 From: efrilm Date: Sun, 6 Sep 2026 17:29:34 +0700 Subject: [PATCH 08/47] feat(product): require a unit for weight-based products MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A product sold by weight with no unit produces order lines with nothing to print: the receipt would read "4,2" with no idea of what. Until now nothing stopped that — the mistake only surfaced at the cashier. Enforce it in two places, because neither alone sees the whole picture. On create, the validator has everything it needs. On update, the request may omit unit_id for a product that already has one, so the check runs in the processor against the merged product: what is rejected is the end state, a product sold by weight with no unit. Also fixes two things this uncovered: The struct tags on the product contracts are decorative — this validator is hand-written and never calls validator.Struct — so `oneof=unit weight` was never enforced, and an unknown sell_by was silently rewritten to "unit" by the mapper. It is now rejected with a message that names the valid values. The update validator's "at least one field" guard did not list unit_id, sell_by or print_to_checker, so an update carrying only one of those was turned away as an empty request. Co-Authored-By: Claude Opus 5 (1M context) --- docs/integration-weight-based-products.md | 26 ++-- internal/mappers/product_mapper.go | 5 + internal/processor/product_processor.go | 8 ++ internal/validator/product_validator.go | 36 ++++- .../product_validator_sell_by_test.go | 130 ++++++++++++++++++ 5 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 internal/validator/product_validator_sell_by_test.go diff --git a/docs/integration-weight-based-products.md b/docs/integration-weight-based-products.md index 61b5b16..1d9d658 100644 --- a/docs/integration-weight-based-products.md +++ b/docs/integration-weight-based-products.md @@ -71,10 +71,14 @@ pendek yang benar-benar ingin ditampilkan. Daftar satuan dibaca lewat `GET /api/ | Field | Tipe | Keterangan | |---|---|---| -| `sell_by` | `"unit"` \| `"weight"` | Opsional, default `"unit"`. Nilai lain ditolak validator. | -| `unit_id` | UUID | Opsional di backend, tapi **wajib secara praktik** untuk `sell_by: "weight"` — lihat §8. | +| `sell_by` | `"unit"` \| `"weight"` | Opsional, default `"unit"`. Nilai lain **ditolak** dengan pesan jelas. | +| `unit_id` | UUID | **Wajib** saat `sell_by: "weight"`, ditolak backend bila kosong. Opsional untuk produk satuan. | | `price` | number | Harga per satu satuan. Rp 4.500 per ons, bukan harga per ikan. | +Pada `PUT /api/v1/products/:id`, `unit_id` **tidak perlu dikirim ulang** bila produknya +sudah punya satuan — mengubah `sell_by` menjadi `"weight"` saja sudah cukup. Yang ditolak +adalah kondisi akhirnya: produk yang dijual per timbangan tanpa satuan. + Keduanya juga bisa diubah lewat `PUT /api/v1/products/:id` dengan bentuk yang sama, dan ikut terbaca di setiap response produk (`GET /api/v1/products`, `/products/all`, `/products/:id`). @@ -84,8 +88,11 @@ ikut terbaca di setiap response produk (`GET /api/v1/products`, `/products/all`, - Kunci `sell_by` **setelah produk punya transaksi**. Mengubah produk lama dari `unit` ke `weight` tidak mengubah baris order yang sudah ada — baris lama tetap dihitung per cacah — tapi akan membingungkan pengguna yang melihat riwayatnya. -- Saat `weight` dipilih, jadikan pemilih satuan sebagai field **wajib** di form. +- Saat `weight` dipilih, jadikan pemilih satuan sebagai field **wajib** di form. Backend + juga menolaknya, tapi ditangkap di form lebih baik daripada baru gagal saat simpan. - Ubah label harga mengikuti satuan yang dipilih: *"Harga per ons"*. +- Untuk produk satuan, pemilih satuan boleh disembunyikan — `unit_id` opsional dan belum + dikonsumsi apa pun di POS. --- @@ -213,6 +220,14 @@ Semua error mengikuti amplop standar. Pesan validasi baru muncul dengan kode `90 Pesan menyebut **nama produk**, sehingga bisa ditampilkan apa adanya ke kasir. +### Setup produk (Backoffice) + +| Pesan (`cause`) | Penyebab | Perbaikan di klien | +|---|---|---| +| `unit_id is required when sell_by is 'weight'` | Produk timbangan dibuat tanpa satuan | Wajibkan pemilih satuan saat Timbangan dipilih | +| `sell_by must be either 'unit' or 'weight'` | Nilai `sell_by` di luar dua itu | Kirim persis `"unit"` atau `"weight"` | +| `product '…' is sold by weight and requires a unit_id` | Update membuat produk jadi timbangan tanpa satuan | Kirim `unit_id` bersama perubahan `sell_by` | + --- ## 7. Kompatibilitas mundur @@ -231,11 +246,6 @@ permintaannya ditolak dengan pesan jelas, bukan gagal diam-diam. ## 8. Batasan yang diketahui -> **Perlu ditangani di frontend.** Backend **belum** memaksa `unit_id` terisi saat -> `sell_by: "weight"`. Produk timbangan tanpa satuan akan tersimpan, tapi baris ordernya -> keluar dengan `unit_abbreviation: null` — struk tidak bisa mencetak "ons". Sampai -> validasi itu ditambahkan di backend, **Backoffice wajib mewajibkannya di form**. - - **Pembulatan uang ke 2 desimal.** `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`, bukan dibulatkan ke rupiah utuh. Bila kasir harus menerima rupiah penuh, ini perlu diputuskan dan diubah di backend lebih dulu (`RoundMoney`, satu tempat). diff --git a/internal/mappers/product_mapper.go b/internal/mappers/product_mapper.go index 57f92ba..c9d1aa4 100644 --- a/internal/mappers/product_mapper.go +++ b/internal/mappers/product_mapper.go @@ -25,7 +25,9 @@ func ProductEntityToModel(entity *entities.Product) *models.Product { BusinessType: constants.BusinessType(entity.BusinessType), ImageURL: entity.ImageURL, PrinterType: entity.PrinterType, + UnitID: entity.UnitID, SellBy: entity.SellBy, + HasIngredients: entity.HasIngredients, Metadata: map[string]interface{}(entity.Metadata), IsActive: entity.IsActive, CreatedAt: entity.CreatedAt, @@ -50,6 +52,9 @@ func ProductModelToEntity(model *models.Product) *entities.Product { BusinessType: string(model.BusinessType), ImageURL: model.ImageURL, PrinterType: model.PrinterType, + UnitID: model.UnitID, + SellBy: model.SellBy, + HasIngredients: model.HasIngredients, Metadata: entities.Metadata(model.Metadata), IsActive: model.IsActive, CreatedAt: model.CreatedAt, diff --git a/internal/processor/product_processor.go b/internal/processor/product_processor.go index f778833..e08ea7c 100644 --- a/internal/processor/product_processor.go +++ b/internal/processor/product_processor.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/logger" "apskel-pos-be/internal/mappers" @@ -191,6 +192,13 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID, mappers.UpdateProductEntityFromRequest(existingProduct, req) + // Checked after the merge, not on the request: switching a product to sell_by + // "weight" is valid when it already carries a unit, and clearing the unit is + // invalid when it is already sold by weight. Only the merged product shows either. + if existingProduct.SellBy == constants.SellByWeight && existingProduct.UnitID == nil { + return nil, fmt.Errorf("product '%s' is sold by weight and requires a unit_id", existingProduct.Name) + } + if err := p.productRepo.Update(ctx, existingProduct); err != nil { return nil, fmt.Errorf("failed to update product: %w", err) } diff --git a/internal/validator/product_validator.go b/internal/validator/product_validator.go index 82d4e22..a5943bb 100644 --- a/internal/validator/product_validator.go +++ b/internal/validator/product_validator.go @@ -63,6 +63,32 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode } + if err, code := validateSellBy(req.SellBy, req.UnitID); err != nil { + return err, code + } + + return nil, "" +} + +// validateSellBy checks how a product is sold and that it carries what that choice +// needs. A weight-based product without a unit would produce order lines with no unit +// to print, so the receipt could show "4,2" with no idea of what. +// +// unitID is the unit the request would leave on the product: for an update that does +// not touch unit_id, pass the product's current one. +func validateSellBy(sellBy *string, unitID *uuid.UUID) (error, string) { + if sellBy == nil { + return nil, "" + } + + if !constants.IsValidSellBy(*sellBy) { + return errors.New("sell_by must be either 'unit' or 'weight'"), constants.MalformedFieldErrorCode + } + + if *sellBy == constants.SellByWeight && unitID == nil { + return errors.New("unit_id is required when sell_by is 'weight'"), constants.MissingFieldErrorCode + } + return nil, "" } @@ -74,7 +100,8 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update // At least one field should be provided for update if req.CategoryID == nil && req.SKU == nil && req.Name == nil && req.Description == nil && req.Price == nil && req.Cost == nil && req.BusinessType == nil && req.ImageURL == nil && - req.PrinterType == nil && req.Metadata == nil && req.IsActive == nil { + req.PrinterType == nil && req.PrintToChecker == nil && req.UnitID == nil && + req.SellBy == nil && req.Metadata == nil && req.IsActive == nil { return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode } @@ -111,6 +138,13 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode } + // Only the value is checked here. Whether the product ends up with a unit depends on + // what it already has, which this request cannot see — the processor checks that + // against the stored product. + if req.SellBy != nil && !constants.IsValidSellBy(*req.SellBy) { + return errors.New("sell_by must be either 'unit' or 'weight'"), constants.MalformedFieldErrorCode + } + return nil, "" } diff --git a/internal/validator/product_validator_sell_by_test.go b/internal/validator/product_validator_sell_by_test.go new file mode 100644 index 0000000..6a6dd56 --- /dev/null +++ b/internal/validator/product_validator_sell_by_test.go @@ -0,0 +1,130 @@ +package validator + +import ( + "testing" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + + "github.com/google/uuid" +) + +func strPtr(s string) *string { return &s } + +func baseCreateRequest() *contract.CreateProductRequest { + return &contract.CreateProductRequest{ + CategoryID: uuid.New(), + Name: "Ikan Tude", + Price: 4500, + } +} + +func TestValidateCreateProductRequestSellBy(t *testing.T) { + unitID := uuid.New() + + tests := []struct { + name string + sellBy *string + unitID *uuid.UUID + wantErr bool + wantMsg string + }{ + { + name: "omitted sell_by is allowed and defaults to unit", + sellBy: nil, unitID: nil, + }, + { + name: "unit product needs no unit_id", + sellBy: strPtr(constants.SellByUnit), unitID: nil, + }, + { + name: "weight product with a unit is accepted", + sellBy: strPtr(constants.SellByWeight), unitID: &unitID, + }, + { + name: "weight product without a unit is rejected", + // Otherwise its order lines would have no unit to print on the receipt. + sellBy: strPtr(constants.SellByWeight), unitID: nil, + wantErr: true, + wantMsg: "unit_id is required when sell_by is 'weight'", + }, + { + name: "unknown sell_by is rejected rather than silently corrected", + // The struct tags on this contract are not enforced — this validator is + // hand-written — so the check has to be explicit. + sellBy: strPtr("pisang"), unitID: &unitID, + wantErr: true, + wantMsg: "sell_by must be either 'unit' or 'weight'", + }, + } + + v := NewProductValidator() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := baseCreateRequest() + req.SellBy = tt.sellBy + req.UnitID = tt.unitID + + err, code := v.ValidateCreateProductRequest(req) + + if !tt.wantErr { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatal("expected an error, got none") + } + if err.Error() != tt.wantMsg { + t.Errorf("message = %q, want %q", err.Error(), tt.wantMsg) + } + if code == "" { + t.Error("expected an error code") + } + }) + } +} + +// An update carrying only sell_by must not be turned away as an empty update. +func TestValidateUpdateProductRequestAcceptsSellByAlone(t *testing.T) { + v := NewProductValidator() + + err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{ + SellBy: strPtr(constants.SellByWeight), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err, _ = v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{}) + if err == nil { + t.Error("an update with no fields at all should be rejected") + } +} + +func TestValidateUpdateProductRequestRejectsUnknownSellBy(t *testing.T) { + v := NewProductValidator() + + err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{ + SellBy: strPtr("timbangan"), + }) + if err == nil { + t.Fatal("expected an error for an unknown sell_by") + } +} + +// The update path deliberately does NOT require unit_id on the request: a product that +// already has a unit can be switched to sell_by "weight" without resending it. That +// pairing is checked by the processor against the stored product. +func TestValidateUpdateProductRequestDefersUnitCheck(t *testing.T) { + v := NewProductValidator() + + err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{ + SellBy: strPtr(constants.SellByWeight), + }) + if err != nil { + t.Fatalf("update should not require unit_id on the request, got: %v", err) + } +} -- 2.54.0 From f2701882dc9bc8e0ab91096f767fffe913c69f3f Mon Sep 17 00:00:00 2001 From: efrilm Date: Sun, 6 Sep 2026 22:44:55 +0700 Subject: [PATCH 09/47] fix(order): carry item weight through the contract-to-model transformer CreateOrderContractToModel and AddToOrderContractToModel copied every order item field except Weight, so a weight sent by the client never reached the processor and every weight-based line failed with "product ... is sold by weight and requires a weight". Co-Authored-By: Claude Opus 5 (1M context) --- internal/transformer/order_transformer.go | 2 + .../transformer/order_transformer_test.go | 48 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 internal/transformer/order_transformer_test.go diff --git a/internal/transformer/order_transformer.go b/internal/transformer/order_transformer.go index 92b8ab8..b297cef 100644 --- a/internal/transformer/order_transformer.go +++ b/internal/transformer/order_transformer.go @@ -17,6 +17,7 @@ func CreateOrderContractToModel(req *contract.CreateOrderRequest) *models.Create ProductID: item.ProductID, ProductVariantID: item.ProductVariantID, Quantity: item.Quantity, + Weight: item.Weight, UnitPrice: item.UnitPrice, // Now optional Modifiers: item.Modifiers, Notes: item.Notes, @@ -58,6 +59,7 @@ func AddToOrderContractToModel(req *contract.AddToOrderRequest) *models.AddToOrd ProductID: item.ProductID, ProductVariantID: item.ProductVariantID, Quantity: item.Quantity, + Weight: item.Weight, UnitPrice: item.UnitPrice, // Now optional Modifiers: item.Modifiers, Notes: item.Notes, diff --git a/internal/transformer/order_transformer_test.go b/internal/transformer/order_transformer_test.go new file mode 100644 index 0000000..c3067a0 --- /dev/null +++ b/internal/transformer/order_transformer_test.go @@ -0,0 +1,48 @@ +package transformer + +import ( + "testing" + + "apskel-pos-be/internal/contract" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestCreateOrderContractToModelCarriesWeight(t *testing.T) { + weight := 1.25 + + result := CreateOrderContractToModel(&contract.CreateOrderRequest{ + OutletID: uuid.New(), + UserID: uuid.New(), + OrderItems: []contract.CreateOrderItemRequest{ + { + ProductID: uuid.New(), + Quantity: 1, + Weight: &weight, + }, + }, + }) + + require.Len(t, result.OrderItems, 1) + require.NotNil(t, result.OrderItems[0].Weight) + require.Equal(t, weight, *result.OrderItems[0].Weight) +} + +func TestAddToOrderContractToModelCarriesWeight(t *testing.T) { + weight := 0.8 + + result := AddToOrderContractToModel(&contract.AddToOrderRequest{ + OrderItems: []contract.CreateOrderItemRequest{ + { + ProductID: uuid.New(), + Quantity: 1, + Weight: &weight, + }, + }, + }) + + require.Len(t, result.OrderItems, 1) + require.NotNil(t, result.OrderItems[0].Weight) + require.Equal(t, weight, *result.OrderItems[0].Weight) +} -- 2.54.0 From 923c108690c565e3a35fee0fc8a29e0f00fb4be9 Mon Sep 17 00:00:00 2001 From: efrilm Date: Sun, 6 Sep 2026 23:06:31 +0700 Subject: [PATCH 10/47] fix(analytics): cost weighed lines by weight, not row count A weight-based line is one weighing, so order_items.quantity is pinned to 1 while unit_price and unit_cost are per unit of weight. Analytics SQL was multiplying and dividing per-unit rates by the raw quantity, costing a 4.2 ons fish as a single ons: standard_hpp_total and moving_average_hpp_total came out far too low across all four product reports, overstating gross profit, and average_price and fifo_hpp_per_unit read per weighing while standard_hpp_per_unit read per unit, so the three HPP figures in one row could not be compared. Adds billableQty and billableQtyNet as the single place that decides the multiplier, mirroring entities.OrderItem.BillableQuantity. quantity_sold and total_items stay as weighing counts; weight_sold already carries the amount. revenue and fifo_hpp_total were already correct via total_price/total_cost. Co-Authored-By: Claude Opus 5 (1M context) --- internal/repository/analytics_repository.go | 46 ++++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index fe23ebc..1696973 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -29,6 +29,20 @@ type AnalyticsRepository interface { GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error) } +// A weight-based line is a single weighing: order_items.quantity is pinned to 1 and +// the weighed amount lives in order_items.weight, while unit_price and unit_cost are +// per unit of weight. Anything that multiplies or divides by a per-unit rate must use +// the weighed amount, not the row count, or a 4.2 ons fish is costed as a single ons. +// These mirror entities.OrderItem.BillableQuantity for aggregate SQL. +const ( + // billableQty is the multiplier for a per-unit price or cost, before refunds. + billableQty = `COALESCE(oi.weight, oi.quantity)` + + // billableQtyNet is billableQty after refunds. A weighed line is always refunded + // whole, so with quantity pinned to 1 the factor is the full weight or zero. + billableQtyNet = `COALESCE(oi.weight, 1) * (oi.quantity - COALESCE(oi.refund_quantity, 0))` +) + type AnalyticsRepositoryImpl struct { db *gorm.DB } @@ -464,7 +478,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ COALESCE(SUM(oi.weight), 0) as weight_sold, COALESCE(SUM(oi.total_price), 0) as revenue, CASE - WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(oi.quantity) + WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(` + billableQty + `) ELSE 0 END as average_price, COUNT(DISTINCT oi.order_id) as order_count, @@ -479,14 +493,14 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id WHERE pr.product_id = p.id - ), p.cost, 0) * COALESCE(SUM(oi.quantity), 0) as standard_hpp_total, + ), p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as standard_hpp_total, CASE - WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(oi.quantity) + WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(` + billableQty + `) ELSE 0 END as fifo_hpp_per_unit, COALESCE(SUM(oi.total_cost), 0) as fifo_hpp_total, COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit, - COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(oi.quantity), 0) as moving_average_hpp_total + COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as moving_average_hpp_total `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). @@ -537,9 +551,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). @@ -590,9 +604,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte COUNT(DISTINCT c.id) as category_count, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). @@ -707,9 +721,9 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C COUNT(DISTINCT c.id) as category_count, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp `). Scan(summary).Error if err != nil { @@ -728,9 +742,9 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp `). Group("c.id, c.name, c.order"). Order("c.order ASC, c.name ASC"). @@ -760,18 +774,18 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue, COALESCE( SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) - / NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0), + / NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0), 0) as average_price, COUNT(DISTINCT oi.order_id) as order_count, COALESCE(shpp.hpp_per_unit, p.cost, 0) as standard_hpp_per_unit, - COALESCE(shpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as standard_hpp_total, + COALESCE(shpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0) as standard_hpp_total, COALESCE( SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END) - / NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0), + / NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0), 0) as fifo_hpp_per_unit, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as fifo_hpp_total, COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit, - COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as moving_average_hpp_total + COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0) as moving_average_hpp_total `). Group("p.id, p.name, p.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, shpp.hpp_per_unit, mahpp.hpp_per_unit"). Order("revenue DESC"). -- 2.54.0 From 84401cc708e4722fbf92c751939ac3fa4fc7b834 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 01:01:01 +0700 Subject: [PATCH 11/47] feat(wallet): add wallet, ledger and lot tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 000090 creates customer_wallets, wallet_transactions, wallet_lots and wallet_lot_allocations as specified in docs/prd-point-coin.md §8 (PC-101). The CHECK constraints enforce K5 at the database: every ledger row names its source or destination, PAYMENT and the other point-only types cannot carry COIN, transfers need a counterparty, reversals need the row they reverse, adjustments need an admin and a reason, and EXPIRE must point at a lot. Balances and lot remainders cannot go negative, and a lot cannot hold more than it was created with. Beyond §8, adds idx_wallet_lot_allocations_lot_id: the primary key cannot serve lookups by lot, which the reconciliation job needs. Co-Authored-By: Claude Opus 5.5 --- .../000090_create_wallet_tables.down.sql | 4 + migrations/000090_create_wallet_tables.up.sql | 131 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 migrations/000090_create_wallet_tables.down.sql create mode 100644 migrations/000090_create_wallet_tables.up.sql diff --git a/migrations/000090_create_wallet_tables.down.sql b/migrations/000090_create_wallet_tables.down.sql new file mode 100644 index 0000000..2369658 --- /dev/null +++ b/migrations/000090_create_wallet_tables.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS wallet_lot_allocations; +DROP TABLE IF EXISTS wallet_lots; +DROP TABLE IF EXISTS wallet_transactions; +DROP TABLE IF EXISTS customer_wallets; diff --git a/migrations/000090_create_wallet_tables.up.sql b/migrations/000090_create_wallet_tables.up.sql new file mode 100644 index 0000000..4dcee08 --- /dev/null +++ b/migrations/000090_create_wallet_tables.up.sql @@ -0,0 +1,131 @@ +-- EnakPoint & EnakCoin wallet (docs/prd-point-coin.md §8). Replaces customer_points +-- and customer_tokens; the old tables stay until their data is migrated (PC-105). +-- +-- Balances are only ever changed together with a ledger row, in one transaction, and +-- every ledger row must name where the value came from or went to (K5). The CHECKs +-- below enforce that at the database so a bug in the application cannot skip it. + +-- One row per customer. Besides holding the balances, this row is the lock every +-- wallet operation for the customer takes first (SELECT ... FOR UPDATE), so +-- concurrent operations on the same customer queue up instead of spending twice. +CREATE TABLE customer_wallets ( + customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE RESTRICT, + organization_id UUID NOT NULL REFERENCES organizations(id), + point_balance BIGINT NOT NULL DEFAULT 0, + coin_balance BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + CONSTRAINT chk_customer_wallets_point_balance CHECK (point_balance >= 0), + CONSTRAINT chk_customer_wallets_coin_balance CHECK (coin_balance >= 0) +); + +-- The ledger. Append-only: rows are never updated or deleted, a correction is a new +-- row (EARN_REVERSAL, PAYMENT_REFUND or ADJUSTMENT) pointing at the one it corrects. +-- ON DELETE RESTRICT on customers means a customer with history can only be +-- deactivated, not hard-deleted. +CREATE TABLE wallet_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, + currency VARCHAR(10) NOT NULL, + type VARCHAR(30) NOT NULL, + -- Signed: positive credits the wallet, negative debits it. + amount BIGINT NOT NULL, + balance_after BIGINT NOT NULL, + -- Ties the two rows of an exchange or a transfer together. + group_id UUID, + + -- Where the value came from (amount > 0) or went to (amount < 0). Required for + -- every type; §8.1 lists which reference_type each type uses. + reference_type VARCHAR(30) NOT NULL, + reference_id UUID NOT NULL, + + counterparty_customer_id UUID REFERENCES customers(id), + reverses_transaction_id UUID REFERENCES wallet_transactions(id), + outlet_id UUID, + -- The admin for ADJUSTMENT, the cashier for PAYMENT / PAYMENT_REFUND via POS. + created_by_user UUID, + reason VARCHAR(255), + + -- Display text frozen at creation, so a later rename of an outlet or a customer + -- does not rewrite history (same idea as the price snapshot on order_items). + description VARCHAR(255) NOT NULL, + -- Snapshot of whatever was used to compute the row: settings, point value, + -- exchange rate, reversal shortfall. + metadata JSONB DEFAULT '{}', + idempotency_key VARCHAR(100) UNIQUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + CONSTRAINT chk_wallet_transactions_currency CHECK (currency IN ('POINT', 'COIN')), + CONSTRAINT chk_wallet_transactions_amount CHECK (amount <> 0), + + -- Only EnakPoint can pay (K2); spending on games and exchanging out are EnakCoin only. + CONSTRAINT chk_wallet_transactions_point_only_types CHECK ( + type NOT IN ('PAYMENT', 'PAYMENT_REFUND', 'EXCHANGE_IN', 'REWARD_REDEEM') + OR currency = 'POINT'), + CONSTRAINT chk_wallet_transactions_coin_only_types CHECK ( + type NOT IN ('EXCHANGE_OUT', 'GAME_SPEND') OR currency = 'COIN'), + + CONSTRAINT chk_wallet_transactions_transfer_counterparty CHECK ( + type NOT IN ('TRANSFER_IN', 'TRANSFER_OUT') OR counterparty_customer_id IS NOT NULL), + CONSTRAINT chk_wallet_transactions_reversal_source CHECK ( + type NOT IN ('EARN_REVERSAL', 'PAYMENT_REFUND') OR reverses_transaction_id IS NOT NULL), + CONSTRAINT chk_wallet_transactions_adjustment_actor CHECK ( + type <> 'ADJUSTMENT' OR (created_by_user IS NOT NULL AND reason IS NOT NULL)), + CONSTRAINT chk_wallet_transactions_expire_lot CHECK ( + type <> 'EXPIRE' OR reference_type = 'LOT') +); + +CREATE INDEX idx_wallet_transactions_customer_id_created_at ON wallet_transactions(customer_id, created_at DESC); +CREATE INDEX idx_wallet_transactions_reference ON wallet_transactions(reference_type, reference_id); +CREATE INDEX idx_wallet_transactions_group_id ON wallet_transactions(group_id); +CREATE INDEX idx_wallet_transactions_counterparty_customer_id ON wallet_transactions(counterparty_customer_id); +CREATE INDEX idx_wallet_transactions_reverses_transaction_id ON wallet_transactions(reverses_transaction_id); + +-- Balance kept per lot (K9). Every credit creates one or more lots with their own +-- expiry, and every debit draws from the lots that expire soonest. A transfer, +-- exchange or refund carries the expiry of the lot it came from and points back at it +-- through origin_lot_id, so each unit can be traced to the EARN, ADJUSTMENT or +-- MIGRATION that first created it. +CREATE TABLE wallet_lots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, + currency VARCHAR(10) NOT NULL, + -- The incoming ledger row that created this lot. + source_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), + origin_lot_id UUID REFERENCES wallet_lots(id), + original_amount BIGINT NOT NULL, + -- The only column in the wallet tables that is ever updated. It is a cached + -- original_amount - SUM(wallet_lot_allocations.amount), kept for fast spending, + -- and the reconciliation job (§7.5) checks it against the allocations. + remaining_amount BIGINT NOT NULL, + -- NULL means the lot never expires. + expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + CONSTRAINT chk_wallet_lots_currency CHECK (currency IN ('POINT', 'COIN')), + CONSTRAINT chk_wallet_lots_original_amount CHECK (original_amount > 0), + CONSTRAINT chk_wallet_lots_remaining_amount CHECK ( + remaining_amount >= 0 AND remaining_amount <= original_amount) +); + +-- Spending order (K9): soonest expiry first, lots without an expiry last. +CREATE INDEX idx_wallet_lots_consume ON wallet_lots(customer_id, currency, expires_at NULLS LAST, created_at) + WHERE remaining_amount > 0; +CREATE INDEX idx_wallet_lots_expiry ON wallet_lots(expires_at) WHERE remaining_amount > 0; + +-- Which lots each outgoing ledger row drew from, and how much from each. +CREATE TABLE wallet_lot_allocations ( + transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), + lot_id UUID NOT NULL REFERENCES wallet_lots(id), + amount BIGINT NOT NULL, + + PRIMARY KEY (transaction_id, lot_id), + CONSTRAINT chk_wallet_lot_allocations_amount CHECK (amount > 0) +); + +-- Not in §8: the primary key cannot serve lookups by lot, which the reconciliation +-- job needs to sum each lot's allocations. +CREATE INDEX idx_wallet_lot_allocations_lot_id ON wallet_lot_allocations(lot_id); -- 2.54.0 From b107f4ef041b0c7b0d7e35284aed53b974c0d13d Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 01:01:02 +0700 Subject: [PATCH 12/47] feat(settings): add organization settings and loyalty setting history Migration 000091 creates organization_settings, a key-value store per organization shaped like outlet_settings, for the loyalty settings that must be the same in every outlet (point value, exchange rate, transfer limits, expiry). Until now there was nowhere to keep organization-level settings. Also creates loyalty_setting_changes, the append-only log of who changed which loyalty setting from what to what (PRD F2), for both organization and outlet settings (PC-102). Co-Authored-By: Claude Opus 5.5 --- ...0091_create_organization_settings.down.sql | 2 ++ ...000091_create_organization_settings.up.sql | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 migrations/000091_create_organization_settings.down.sql create mode 100644 migrations/000091_create_organization_settings.up.sql diff --git a/migrations/000091_create_organization_settings.down.sql b/migrations/000091_create_organization_settings.down.sql new file mode 100644 index 0000000..62d6a91 --- /dev/null +++ b/migrations/000091_create_organization_settings.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS loyalty_setting_changes; +DROP TABLE IF EXISTS organization_settings; diff --git a/migrations/000091_create_organization_settings.up.sql b/migrations/000091_create_organization_settings.up.sql new file mode 100644 index 0000000..06e9ed1 --- /dev/null +++ b/migrations/000091_create_organization_settings.up.sql @@ -0,0 +1,35 @@ +-- Settings that must be the same in every outlet of an organization, starting with the +-- loyalty ones (docs/prd-point-coin.md F2, F12): point value, exchange rate, transfer +-- limits and expiry. Same key-value shape as outlet_settings. +CREATE TABLE organization_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + key VARCHAR(255) NOT NULL, + value TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(organization_id, key) +); + +-- The unique constraint leads with organization_id, so it also serves the plain +-- per-organization lookups and there is no separate index on that column. + +-- Who changed which loyalty setting, from what, to what (F2). Covers both the +-- organization settings above and the per-outlet loyalty keys in outlet_settings. +-- Append-only. Values are stored as text, the same as in the settings tables. +CREATE TABLE loyalty_setting_changes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + -- NULL for an organization setting. + outlet_id UUID, + key VARCHAR(100) NOT NULL, + -- NULL when the key had no stored value yet (it was on its default). + old_value TEXT, + new_value TEXT, + changed_by UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_loyalty_setting_changes_organization_id_created_at ON loyalty_setting_changes(organization_id, created_at DESC); +CREATE INDEX idx_loyalty_setting_changes_outlet_id_created_at ON loyalty_setting_changes(outlet_id, created_at DESC) + WHERE outlet_id IS NOT NULL; -- 2.54.0 From fc5eecb68ace46d984f86a6565c986599820cf8c Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 01:01:03 +0700 Subject: [PATCH 13/47] feat(wallet): add wallet entities and repository Entities for the four wallet tables and a WalletRepository that the wallet processor will build on (PC-103). Every method goes through the caller's transaction, and writes and locks refuse to run without one: outside a transaction a lock is released as soon as it is taken and a balance could move without its ledger row. - LockWallet creates the wallet on first use, taking the organization from the customer, then locks it with SELECT ... FOR UPDATE. - LockWallets always locks in customer_id order so opposite transfers cannot deadlock. - AddBalance and ConsumeLot are conditional updates that return an error when they would overdraw, instead of tripping the CHECK constraint. - ListActiveLots returns unexpired lots with balance in K9 spending order. The tests need a real Postgres and run only when TEST_DATABASE_URL points at a migrated database. Both the lock and the lock ordering were checked by removing them and watching the tests fail (lost update, deadlock detected). Co-Authored-By: Claude Opus 5.5 --- internal/constants/wallet.go | 44 +++ internal/entities/entities.go | 5 + internal/entities/wallet.go | 113 ++++++ internal/repository/wallet_repository.go | 261 +++++++++++++ internal/repository/wallet_repository_test.go | 353 ++++++++++++++++++ 5 files changed, 776 insertions(+) create mode 100644 internal/constants/wallet.go create mode 100644 internal/entities/wallet.go create mode 100644 internal/repository/wallet_repository.go create mode 100644 internal/repository/wallet_repository_test.go diff --git a/internal/constants/wallet.go b/internal/constants/wallet.go new file mode 100644 index 0000000..2892fdd --- /dev/null +++ b/internal/constants/wallet.go @@ -0,0 +1,44 @@ +package constants + +// The two balances a customer wallet holds (docs/prd-point-coin.md). EnakPoint pays +// for orders; EnakCoin is spent on games and can be exchanged into EnakPoint. +const ( + WalletCurrencyPoint = "POINT" + WalletCurrencyCoin = "COIN" +) + +func IsValidWalletCurrency(currency string) bool { + return currency == WalletCurrencyPoint || currency == WalletCurrencyCoin +} + +// Ledger row types. §8.1 of the PRD lists, per type, which currency it may use, which +// way it moves the balance, and which reference it must carry. +const ( + WalletTxTypeEarn = "EARN" + WalletTxTypeEarnReversal = "EARN_REVERSAL" + WalletTxTypePayment = "PAYMENT" + WalletTxTypePaymentRefund = "PAYMENT_REFUND" + WalletTxTypeExchangeOut = "EXCHANGE_OUT" + WalletTxTypeExchangeIn = "EXCHANGE_IN" + WalletTxTypeTransferOut = "TRANSFER_OUT" + WalletTxTypeTransferIn = "TRANSFER_IN" + WalletTxTypeGameSpend = "GAME_SPEND" + WalletTxTypeExpire = "EXPIRE" + WalletTxTypeAdjustment = "ADJUSTMENT" + WalletTxTypeMigration = "MIGRATION" + WalletTxTypeRewardRedeem = "REWARD_REDEEM" +) + +// What a ledger row's reference_id points at: where the value came from for a +// credit, or where it went for a debit. +const ( + WalletRefTypeOrder = "ORDER" + WalletRefTypePayment = "PAYMENT" + WalletRefTypeWalletTx = "WALLET_TX" + WalletRefTypeGamePlay = "GAME_PLAY" + WalletRefTypeLot = "LOT" + WalletRefTypeUser = "USER" + WalletRefTypeLegacyPoints = "LEGACY_POINTS" + WalletRefTypeLegacyTokens = "LEGACY_TOKENS" + WalletRefTypeRewardRedemption = "REWARD_REDEMPTION" +) diff --git a/internal/entities/entities.go b/internal/entities/entities.go index a5d6973..e26763b 100644 --- a/internal/entities/entities.go +++ b/internal/entities/entities.go @@ -44,6 +44,11 @@ func GetAllEntities() []interface{} { &ProductOutletPrice{}, &Expense{}, &CashAdvance{}, + // Wallet entities + &CustomerWallet{}, + &WalletTransaction{}, + &WalletLot{}, + &WalletLotAllocation{}, } } diff --git a/internal/entities/wallet.go b/internal/entities/wallet.go new file mode 100644 index 0000000..7b78cb2 --- /dev/null +++ b/internal/entities/wallet.go @@ -0,0 +1,113 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// CustomerWallet holds a customer's EnakPoint and EnakCoin balances. The row is also +// the lock every wallet operation for the customer takes first, so concurrent +// operations on one customer queue up instead of spending the same balance twice. +// +// Balances are never written directly: they only move together with a ledger row, and +// only through the wallet processor. +type CustomerWallet struct { + CustomerID uuid.UUID `gorm:"type:uuid;primary_key" json:"customer_id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` + PointBalance int64 `gorm:"not null;default:0" json:"point_balance"` + CoinBalance int64 `gorm:"not null;default:0" json:"coin_balance"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (CustomerWallet) TableName() string { + return "customer_wallets" +} + +// WalletTransaction is one ledger row. The ledger is append-only: a correction is a +// new row pointing at the one it corrects, never an update. +type WalletTransaction struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` + CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"` + Currency string `gorm:"not null;size:10" json:"currency"` + Type string `gorm:"not null;size:30" json:"type"` + // Signed: positive credits the wallet, negative debits it. + Amount int64 `gorm:"not null" json:"amount"` + BalanceAfter int64 `gorm:"not null" json:"balance_after"` + GroupID *uuid.UUID `gorm:"type:uuid" json:"group_id"` + + // Where the value came from (credit) or went to (debit). + ReferenceType string `gorm:"not null;size:30" json:"reference_type"` + ReferenceID uuid.UUID `gorm:"type:uuid;not null" json:"reference_id"` + + CounterpartyCustomerID *uuid.UUID `gorm:"type:uuid" json:"counterparty_customer_id"` + ReversesTransactionID *uuid.UUID `gorm:"type:uuid" json:"reverses_transaction_id"` + OutletID *uuid.UUID `gorm:"type:uuid" json:"outlet_id"` + CreatedByUser *uuid.UUID `gorm:"type:uuid" json:"created_by_user"` + Reason *string `gorm:"size:255" json:"reason"` + + // Frozen at creation, so later renames do not rewrite history. + Description string `gorm:"not null;size:255" json:"description"` + Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` + IdempotencyKey *string `gorm:"size:100;unique" json:"idempotency_key"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +func (t *WalletTransaction) BeforeCreate(tx *gorm.DB) error { + if t.ID == uuid.Nil { + t.ID = uuid.New() + } + // A nil map would be stored as JSON null rather than an empty object. + if t.Metadata == nil { + t.Metadata = Metadata{} + } + return nil +} + +func (WalletTransaction) TableName() string { + return "wallet_transactions" +} + +// WalletLot is one credited piece of balance with its own expiry (K9). Debits draw from +// the lots that expire soonest. A lot created by a transfer, exchange or refund carries +// the expiry of the lot it came from and points back at it through OriginLotID. +type WalletLot struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` + CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"` + Currency string `gorm:"not null;size:10" json:"currency"` + SourceTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"source_transaction_id"` + OriginLotID *uuid.UUID `gorm:"type:uuid" json:"origin_lot_id"` + OriginalAmount int64 `gorm:"not null" json:"original_amount"` + // A cache of OriginalAmount minus the lot's allocations, and the only wallet column + // that is ever updated. + RemainingAmount int64 `gorm:"not null" json:"remaining_amount"` + // Nil means the lot never expires. + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +func (l *WalletLot) BeforeCreate(tx *gorm.DB) error { + if l.ID == uuid.Nil { + l.ID = uuid.New() + } + return nil +} + +func (WalletLot) TableName() string { + return "wallet_lots" +} + +// WalletLotAllocation records how much a debit ledger row drew from one lot. +type WalletLotAllocation struct { + TransactionID uuid.UUID `gorm:"type:uuid;primary_key" json:"transaction_id"` + LotID uuid.UUID `gorm:"type:uuid;primary_key" json:"lot_id"` + Amount int64 `gorm:"not null" json:"amount"` +} + +func (WalletLotAllocation) TableName() string { + return "wallet_lot_allocations" +} diff --git a/internal/repository/wallet_repository.go b/internal/repository/wallet_repository.go new file mode 100644 index 0000000..b33f761 --- /dev/null +++ b/internal/repository/wallet_repository.go @@ -0,0 +1,261 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" +) + +var ( + // ErrWalletTxRequired is returned by every write and lock when the context carries + // no transaction from TxManager. Outside a transaction a lock is released as soon as + // it is taken, and a balance could move without its ledger row. + ErrWalletTxRequired = errors.New("wallet: operation must run inside a transaction") + // ErrWalletNotFound means the customer does not exist, so no wallet could be made. + ErrWalletNotFound = errors.New("wallet: customer not found") + // ErrWalletInsufficientBalance means a conditional update matched no row because + // the balance would have gone negative. + ErrWalletInsufficientBalance = errors.New("wallet: insufficient balance") + // ErrWalletLotInsufficient means a lot had less remaining than was taken from it. + ErrWalletLotInsufficient = errors.New("wallet: lot has insufficient remaining amount") +) + +// WalletRepository reads and writes the wallet tables (docs/prd-point-coin.md §7, §8). +// Only the wallet processor should call its write methods: it is the one place that +// keeps balances, ledger rows and lots in step. +// +// Unlike the gamification repositories, every method goes through DBFromContext so it +// joins the caller's transaction. Writes and locks refuse to run without one. +type WalletRepository interface { + // LockWallet locks the customer's wallet row for the rest of the transaction, + // creating the row first if the customer has none yet. + LockWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) + // LockWallets locks two wallets, always in customer_id order so that two transfers + // in opposite directions cannot deadlock. The results come back in argument order. + LockWallets(ctx context.Context, a, b uuid.UUID) (*entities.CustomerWallet, *entities.CustomerWallet, error) + // AddBalance moves one balance by delta and returns the new balance. A debit that + // would make it negative changes nothing and returns ErrWalletInsufficientBalance. + AddBalance(ctx context.Context, customerID uuid.UUID, currency string, delta int64) (int64, error) + GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) + + CreateTransaction(ctx context.Context, walletTx *entities.WalletTransaction) error + // GetTransactionByIdempotencyKey returns nil, nil when no row has the key. + GetTransactionByIdempotencyKey(ctx context.Context, key string) (*entities.WalletTransaction, error) + + CreateLot(ctx context.Context, lot *entities.WalletLot) error + // ListActiveLots returns the lots that still have balance and have not expired at + // asOf, in the order they are spent (K9): soonest expiry first, lots without an + // expiry last, oldest first within the same expiry. + ListActiveLots(ctx context.Context, customerID uuid.UUID, currency string, asOf time.Time) ([]entities.WalletLot, error) + // ConsumeLot takes amount from a lot's remaining amount. Taking more than remains + // changes nothing and returns ErrWalletLotInsufficient. + ConsumeLot(ctx context.Context, lotID uuid.UUID, amount int64) error + + CreateAllocations(ctx context.Context, allocations []entities.WalletLotAllocation) error + ListAllocationsByTransaction(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error) +} + +type walletRepository struct { + db *gorm.DB +} + +func NewWalletRepository(db *gorm.DB) WalletRepository { + return &walletRepository{db: db} +} + +// txDB returns the caller's transaction, or ErrWalletTxRequired if there is none. +func (r *walletRepository) txDB(ctx context.Context) (*gorm.DB, error) { + if tx, ok := ctx.Value(txKey).(*gorm.DB); ok && tx != nil { + return tx.WithContext(ctx), nil + } + return nil, ErrWalletTxRequired +} + +func (r *walletRepository) LockWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) { + db, err := r.txDB(ctx) + if err != nil { + return nil, err + } + + // The wallet takes its organization from the customer, so the two cannot disagree. + // ON CONFLICT covers two first operations racing to create the same wallet. + err = db.Exec(`INSERT INTO customer_wallets (customer_id, organization_id) + SELECT id, organization_id FROM customers WHERE id = ? + ON CONFLICT (customer_id) DO NOTHING`, customerID).Error + if err != nil { + return nil, fmt.Errorf("failed to create customer wallet: %w", err) + } + + var wallet entities.CustomerWallet + err = db.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("customer_id = ?", customerID). + First(&wallet).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrWalletNotFound + } + return nil, fmt.Errorf("failed to lock customer wallet: %w", err) + } + return &wallet, nil +} + +func (r *walletRepository) LockWallets(ctx context.Context, a, b uuid.UUID) (*entities.CustomerWallet, *entities.CustomerWallet, error) { + if a == b { + return nil, nil, errors.New("wallet: cannot lock the same wallet twice") + } + + ids := []uuid.UUID{a, b} + sort.Slice(ids, func(i, j int) bool { return ids[i].String() < ids[j].String() }) + + locked := make(map[uuid.UUID]*entities.CustomerWallet, 2) + for _, id := range ids { + wallet, err := r.LockWallet(ctx, id) + if err != nil { + return nil, nil, err + } + locked[id] = wallet + } + return locked[a], locked[b], nil +} + +func (r *walletRepository) AddBalance(ctx context.Context, customerID uuid.UUID, currency string, delta int64) (int64, error) { + db, err := r.txDB(ctx) + if err != nil { + return 0, err + } + + var column string + switch currency { + case constants.WalletCurrencyPoint: + column = "point_balance" + case constants.WalletCurrencyCoin: + column = "coin_balance" + default: + return 0, fmt.Errorf("wallet: unknown currency %q", currency) + } + + // The WHERE clause makes an overdraft match no row instead of tripping the CHECK, + // so the caller gets a clean error and the transaction stays usable. + var balances []int64 + err = db.Raw(`UPDATE customer_wallets + SET `+column+` = `+column+` + ?, updated_at = NOW() + WHERE customer_id = ? AND `+column+` + ? >= 0 + RETURNING `+column, delta, customerID, delta). + Scan(&balances).Error + if err != nil { + return 0, fmt.Errorf("failed to update wallet balance: %w", err) + } + if len(balances) == 0 { + if delta >= 0 { + return 0, ErrWalletNotFound + } + return 0, ErrWalletInsufficientBalance + } + return balances[0], nil +} + +func (r *walletRepository) GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) { + var wallet entities.CustomerWallet + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("customer_id = ?", customerID). + First(&wallet).Error + if err != nil { + return nil, err + } + return &wallet, nil +} + +func (r *walletRepository) CreateTransaction(ctx context.Context, walletTx *entities.WalletTransaction) error { + db, err := r.txDB(ctx) + if err != nil { + return err + } + return db.Create(walletTx).Error +} + +func (r *walletRepository) GetTransactionByIdempotencyKey(ctx context.Context, key string) (*entities.WalletTransaction, error) { + var walletTx entities.WalletTransaction + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("idempotency_key = ?", key). + First(&walletTx).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, fmt.Errorf("failed to get wallet transaction by idempotency key: %w", err) + } + return &walletTx, nil +} + +func (r *walletRepository) CreateLot(ctx context.Context, lot *entities.WalletLot) error { + db, err := r.txDB(ctx) + if err != nil { + return err + } + return db.Create(lot).Error +} + +func (r *walletRepository) ListActiveLots(ctx context.Context, customerID uuid.UUID, currency string, asOf time.Time) ([]entities.WalletLot, error) { + var lots []entities.WalletLot + // Filter and order match idx_wallet_lots_consume. + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("customer_id = ? AND currency = ? AND remaining_amount > 0", customerID, currency). + Where("(expires_at IS NULL OR expires_at > ?)", asOf). + Order("expires_at NULLS LAST, created_at, id"). + Find(&lots).Error + if err != nil { + return nil, fmt.Errorf("failed to list active wallet lots: %w", err) + } + return lots, nil +} + +func (r *walletRepository) ConsumeLot(ctx context.Context, lotID uuid.UUID, amount int64) error { + if amount <= 0 { + return fmt.Errorf("wallet: lot consumption must be positive, got %d", amount) + } + db, err := r.txDB(ctx) + if err != nil { + return err + } + + result := db.Exec(`UPDATE wallet_lots SET remaining_amount = remaining_amount - ? + WHERE id = ? AND remaining_amount >= ?`, amount, lotID, amount) + if result.Error != nil { + return fmt.Errorf("failed to consume wallet lot: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrWalletLotInsufficient + } + return nil +} + +func (r *walletRepository) CreateAllocations(ctx context.Context, allocations []entities.WalletLotAllocation) error { + if len(allocations) == 0 { + return nil + } + db, err := r.txDB(ctx) + if err != nil { + return err + } + return db.Create(&allocations).Error +} + +func (r *walletRepository) ListAllocationsByTransaction(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error) { + var allocations []entities.WalletLotAllocation + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("transaction_id = ?", transactionID). + Find(&allocations).Error + if err != nil { + return nil, fmt.Errorf("failed to list wallet lot allocations: %w", err) + } + return allocations, nil +} diff --git a/internal/repository/wallet_repository_test.go b/internal/repository/wallet_repository_test.go new file mode 100644 index 0000000..bfbdd30 --- /dev/null +++ b/internal/repository/wallet_repository_test.go @@ -0,0 +1,353 @@ +package repository + +import ( + "context" + "errors" + "os" + "sync" + "testing" + "time" + + "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/entities" +) + +// These tests need a real Postgres, because what they check (row locks and +// conditional updates) only exists there. Point TEST_DATABASE_URL at a database with +// all migrations applied, e.g. +// +// TEST_DATABASE_URL=postgres://user:pass@localhost:5432/pos_test?sslmode=disable go test ./internal/repository/ -run Wallet +// +// Each test creates its own organization and customers and removes them afterwards. +func walletTestDB(t *testing.T) *gorm.DB { + t.Helper() + 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) + return db +} + +type walletFixture struct { + db *gorm.DB + repo WalletRepository + txm *TxManager + orgID uuid.UUID + customers []uuid.UUID +} + +func newWalletFixture(t *testing.T, customerCount int) *walletFixture { + t.Helper() + db := walletTestDB(t) + f := &walletFixture{db: db, repo: NewWalletRepository(db), txm: NewTxManager(db), orgID: uuid.New()} + + require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet test', 'basic')`, f.orgID).Error) + for i := 0; i < customerCount; i++ { + id := uuid.New() + require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'wallet test')`, id, f.orgID).Error) + f.customers = append(f.customers, id) + } + + t.Cleanup(func() { + for _, q := range []string{ + `DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, + `DELETE FROM wallet_lots WHERE customer_id IN ?`, + `DELETE FROM wallet_transactions WHERE customer_id IN ?`, + `DELETE FROM customer_wallets WHERE customer_id IN ?`, + `DELETE FROM customers WHERE id IN ?`, + } { + db.Exec(q, f.customers) + } + db.Exec(`DELETE FROM organizations WHERE id = ?`, f.orgID) + }) + return f +} + +// inTx runs fn in a transaction and fails the test on error. +func (f *walletFixture) inTx(t *testing.T, fn func(ctx context.Context) error) { + t.Helper() + require.NoError(t, f.txm.WithTransaction(context.Background(), fn)) +} + +// credit writes a ledger row and a lot and moves the balance, the minimum the +// database accepts for a credit. +func (f *walletFixture) credit(t *testing.T, ctx context.Context, customerID uuid.UUID, amount int64, expiresAt *time.Time) *entities.WalletLot { + t.Helper() + balance, err := f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyPoint, amount) + require.NoError(t, err) + walletTx := &entities.WalletTransaction{ + OrganizationID: f.orgID, + CustomerID: customerID, + Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypeMigration, + Amount: amount, + BalanceAfter: balance, + ReferenceType: constants.WalletRefTypeLegacyPoints, + ReferenceID: uuid.New(), + Description: "test", + } + require.NoError(t, f.repo.CreateTransaction(ctx, walletTx)) + lot := &entities.WalletLot{ + OrganizationID: f.orgID, + CustomerID: customerID, + Currency: constants.WalletCurrencyPoint, + SourceTransactionID: walletTx.ID, + OriginalAmount: amount, + RemainingAmount: amount, + ExpiresAt: expiresAt, + } + require.NoError(t, f.repo.CreateLot(ctx, lot)) + return lot +} + +func TestWalletRepository_WritesRequireTransaction(t *testing.T) { + f := newWalletFixture(t, 1) + ctx := context.Background() + + _, err := f.repo.LockWallet(ctx, f.customers[0]) + assert.ErrorIs(t, err, ErrWalletTxRequired) + _, err = f.repo.AddBalance(ctx, f.customers[0], constants.WalletCurrencyPoint, 10) + assert.ErrorIs(t, err, ErrWalletTxRequired) + assert.ErrorIs(t, f.repo.ConsumeLot(ctx, uuid.New(), 1), ErrWalletTxRequired) + assert.ErrorIs(t, f.repo.CreateTransaction(ctx, &entities.WalletTransaction{}), ErrWalletTxRequired) + assert.ErrorIs(t, f.repo.CreateLot(ctx, &entities.WalletLot{}), ErrWalletTxRequired) +} + +func TestWalletRepository_LockWalletCreatesWallet(t *testing.T) { + f := newWalletFixture(t, 1) + + f.inTx(t, func(ctx context.Context) error { + wallet, err := f.repo.LockWallet(ctx, f.customers[0]) + require.NoError(t, err) + assert.Equal(t, f.orgID, wallet.OrganizationID, "organization comes from the customer") + assert.Zero(t, wallet.PointBalance) + assert.Zero(t, wallet.CoinBalance) + + // Locking again in the same transaction finds the same row. + again, err := f.repo.LockWallet(ctx, f.customers[0]) + require.NoError(t, err) + assert.Equal(t, wallet.CustomerID, again.CustomerID) + return nil + }) + + f.inTx(t, func(ctx context.Context) error { + _, err := f.repo.LockWallet(ctx, uuid.New()) + assert.ErrorIs(t, err, ErrWalletNotFound) + return nil + }) +} + +func TestWalletRepository_AddBalanceRejectsOverdraft(t *testing.T) { + f := newWalletFixture(t, 1) + customerID := f.customers[0] + + f.inTx(t, func(ctx context.Context) error { + _, err := f.repo.LockWallet(ctx, customerID) + require.NoError(t, err) + + balance, err := f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyPoint, 5) + require.NoError(t, err) + assert.Equal(t, int64(5), balance) + + _, err = f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyPoint, -6) + assert.ErrorIs(t, err, ErrWalletInsufficientBalance) + + // Coin is a separate balance: point balance does not cover it. + _, err = f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyCoin, -1) + assert.ErrorIs(t, err, ErrWalletInsufficientBalance) + + // The failed update left the transaction usable and the balance untouched. + balance, err = f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyPoint, -5) + require.NoError(t, err) + assert.Equal(t, int64(0), balance) + return nil + }) + + wallet, err := f.repo.GetWallet(context.Background(), customerID) + require.NoError(t, err) + assert.Equal(t, int64(0), wallet.PointBalance) + assert.Equal(t, int64(0), wallet.CoinBalance) +} + +func TestWalletRepository_AddBalanceWithoutWallet(t *testing.T) { + f := newWalletFixture(t, 1) + + f.inTx(t, func(ctx context.Context) error { + _, err := f.repo.AddBalance(ctx, f.customers[0], constants.WalletCurrencyPoint, 5) + assert.ErrorIs(t, err, ErrWalletNotFound) + _, err = f.repo.AddBalance(ctx, f.customers[0], "GOLD", 5) + assert.Error(t, err) + return nil + }) +} + +func TestWalletRepository_ConsumeLotRejectsOverdraw(t *testing.T) { + f := newWalletFixture(t, 1) + customerID := f.customers[0] + + f.inTx(t, func(ctx context.Context) error { + _, err := f.repo.LockWallet(ctx, customerID) + require.NoError(t, err) + lot := f.credit(t, ctx, customerID, 10, nil) + + require.NoError(t, f.repo.ConsumeLot(ctx, lot.ID, 4)) + assert.ErrorIs(t, f.repo.ConsumeLot(ctx, lot.ID, 7), ErrWalletLotInsufficient) + require.NoError(t, f.repo.ConsumeLot(ctx, lot.ID, 6)) + assert.ErrorIs(t, f.repo.ConsumeLot(ctx, lot.ID, 1), ErrWalletLotInsufficient) + assert.Error(t, f.repo.ConsumeLot(ctx, lot.ID, 0)) + return nil + }) +} + +// Two goroutines lock the same wallet and do a read-modify-write with a pause in +// between. Without the lock both would read 0 and the result would be 1. +func TestWalletRepository_LockWalletSerializes(t *testing.T) { + f := newWalletFixture(t, 1) + customerID := f.customers[0] + + // Create the wallet up front. Otherwise the second goroutine's INSERT ... ON + // CONFLICT waits on the first one's uncommitted insert, which serializes them + // even without FOR UPDATE and the test would prove nothing about the lock. + f.inTx(t, func(ctx context.Context) error { + _, err := f.repo.LockWallet(ctx, customerID) + return err + }) + + type window struct{ locked, released time.Time } + windows := make([]window, 2) + var wg sync.WaitGroup + errs := make(chan error, 2) + + for i := 0; i < 2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs <- f.txm.WithTransaction(context.Background(), func(ctx context.Context) error { + wallet, err := f.repo.LockWallet(ctx, customerID) + if err != nil { + return err + } + windows[i].locked = time.Now() + time.Sleep(300 * time.Millisecond) + db := DBFromContext(ctx, f.db) + if err := db.Exec(`UPDATE customer_wallets SET point_balance = ? WHERE customer_id = ?`, + wallet.PointBalance+1, customerID).Error; err != nil { + return err + } + windows[i].released = time.Now() + return nil + }) + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + + wallet, err := f.repo.GetWallet(context.Background(), customerID) + require.NoError(t, err) + assert.Equal(t, int64(2), wallet.PointBalance, "second transaction must see the first one's write") + + first, second := windows[0], windows[1] + if second.locked.Before(first.locked) { + first, second = second, first + } + assert.False(t, second.locked.Before(first.released), "second lock was taken while the first was held") +} + +// Transfers in opposite directions lock the same pair of wallets. Because LockWallets +// always locks in customer_id order, they queue instead of deadlocking. +func TestWalletRepository_LockWalletsOppositeOrderDoesNotDeadlock(t *testing.T) { + f := newWalletFixture(t, 2) + a, b := f.customers[0], f.customers[1] + // Existing wallets, for the same reason as in LockWalletSerializes. + f.inTx(t, func(ctx context.Context) error { + _, _, err := f.repo.LockWallets(ctx, a, b) + return err + }) + + var wg sync.WaitGroup + errs := make(chan error, 20) + for i := 0; i < 10; i++ { + for _, pair := range [][2]uuid.UUID{{a, b}, {b, a}} { + wg.Add(1) + go func(first, second uuid.UUID) { + defer wg.Done() + errs <- f.txm.WithTransaction(context.Background(), func(ctx context.Context) error { + w1, w2, err := f.repo.LockWallets(ctx, first, second) + if err != nil { + return err + } + if w1.CustomerID != first || w2.CustomerID != second { + return errors.New("wallets returned out of argument order") + } + time.Sleep(20 * time.Millisecond) + return nil + }) + }(pair[0], pair[1]) + } + } + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + + f.inTx(t, func(ctx context.Context) error { + _, _, err := f.repo.LockWallets(ctx, a, a) + assert.Error(t, err) + return nil + }) +} + +func TestWalletRepository_ListActiveLotsOrder(t *testing.T) { + f := newWalletFixture(t, 1) + customerID := f.customers[0] + now := time.Now() + at := func(d time.Duration) *time.Time { v := now.Add(d); return &v } + + create := func(expiresAt *time.Time) *entities.WalletLot { + var lot *entities.WalletLot + f.inTx(t, func(ctx context.Context) error { + _, err := f.repo.LockWallet(ctx, customerID) + require.NoError(t, err) + lot = f.credit(t, ctx, customerID, 10, expiresAt) + return nil + }) + return lot + } + neverOld := create(nil) + late := create(at(48 * time.Hour)) + soon := create(at(time.Hour)) + neverNew := create(nil) + expired := create(at(-time.Hour)) + empty := create(at(30 * time.Minute)) + f.inTx(t, func(ctx context.Context) error { + return f.repo.ConsumeLot(ctx, empty.ID, 10) + }) + + lots, err := f.repo.ListActiveLots(context.Background(), customerID, constants.WalletCurrencyPoint, now) + require.NoError(t, err) + + var got []uuid.UUID + for _, lot := range lots { + got = append(got, lot.ID) + } + assert.Equal(t, []uuid.UUID{soon.ID, late.ID, neverOld.ID, neverNew.ID}, got, + "soonest expiry first, no expiry last and oldest first, expired and empty lots left out") + assert.NotContains(t, got, expired.ID) + + coinLots, err := f.repo.ListActiveLots(context.Background(), customerID, constants.WalletCurrencyCoin, now) + require.NoError(t, err) + assert.Empty(t, coinLots) +} -- 2.54.0 From 2eb590caabbbdd2502958ec4e691d25e54a9ee7f Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 08:47:37 +0700 Subject: [PATCH 14/47] feat(wallet): add wallet engine as the only way to change a balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WalletProcessor writes the balance, the ledger row and the lots or allocations together, which keeps SUM(ledger) = balance = SUM(lot remaining) (docs/prd-point-coin.md §7.5, PC-104). - Credit writes the ledger row and creates lots, each with its own expiry and origin lot. - Debit draws from the preferred lots first (a reversal's own lots, or the lot being expired), then from unexpired lots in K9 order, and returns the allocations with their expiry so CarryOver can give the receiving side of a transfer or exchange the same expiry. - DebitUpTo takes what the wallet has and reports the shortfall (F10, Q3). - An idempotency key returns the first result; reusing it for a different operation is an error. - §8.1 is checked in code from one rule table, ahead of the database constraints, so callers get a readable error. Each method locks the wallet itself, after validating the input and before checking the idempotency key, so correctness does not depend on the caller. Operations on two wallets still call LockWallets first to keep lock order. Unit tests run on an in-memory repository and check the §7.5 invariants after every scenario; one more test runs the engine against Postgres when TEST_DATABASE_URL is set. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 4 + internal/processor/wallet_processor.go | 527 ++++++++++++ .../processor/wallet_processor_db_test.go | 142 ++++ internal/processor/wallet_processor_test.go | 788 ++++++++++++++++++ internal/repository/wallet_repository.go | 31 + 5 files changed, 1492 insertions(+) create mode 100644 internal/processor/wallet_processor.go create mode 100644 internal/processor/wallet_processor_db_test.go create mode 100644 internal/processor/wallet_processor_test.go diff --git a/internal/app/app.go b/internal/app/app.go index ace698f..932aeb4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -247,6 +247,7 @@ type repositories struct { productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl expenseRepo *repository.ExpenseRepositoryImpl cashAdvanceRepo *repository.CashAdvanceRepositoryImpl + walletRepo repository.WalletRepository } func (a *App) initRepositories() *repositories { @@ -302,6 +303,7 @@ func (a *App) initRepositories() *repositories { productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db), expenseRepo: repository.NewExpenseRepositoryImpl(a.db), cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db), + walletRepo: repository.NewWalletRepository(a.db), } } @@ -350,6 +352,7 @@ type processors struct { productOutletPriceProcessor processor.ProductOutletPriceProcessor expenseProcessor *processor.ExpenseProcessorImpl cashAdvanceProcessor *processor.CashAdvanceProcessorImpl + walletProcessor *processor.WalletProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -403,6 +406,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo), expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo), cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo), + walletProcessor: processor.NewWalletProcessor(repos.walletRepo), } } diff --git a/internal/processor/wallet_processor.go b/internal/processor/wallet_processor.go new file mode 100644 index 0000000..3190fa3 --- /dev/null +++ b/internal/processor/wallet_processor.go @@ -0,0 +1,527 @@ +package processor + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/repository" +) + +var ( + // ErrWalletInvalidEntry wraps every rejection of an entry that breaks the rules in + // docs/prd-point-coin.md §8.1. The database enforces most of them too; checking + // here first gives callers a readable error instead of a constraint name. + ErrWalletInvalidEntry = errors.New("wallet: invalid entry") + // ErrWalletIdempotencyConflict means an idempotency key was reused for a different + // operation. Retrying the same operation with the same key is not a conflict. + ErrWalletIdempotencyConflict = errors.New("wallet: idempotency key already used for a different operation") +) + +// WalletEntry is what every ledger row needs, whichever way it moves the balance. +// Which of the optional fields a type requires is listed in §8.1. +type WalletEntry struct { + // Optional. Set it when another row must reference this one before it exists, as + // the two rows of an exchange or a transfer do. + TransactionID uuid.UUID + + CustomerID uuid.UUID + Currency string + Type string + // Always positive: Credit adds it, Debit takes it away. + Amount int64 + + ReferenceType string + ReferenceID uuid.UUID + + GroupID *uuid.UUID + CounterpartyCustomerID *uuid.UUID + ReversesTransactionID *uuid.UUID + OutletID *uuid.UUID + CreatedByUser *uuid.UUID + Reason *string + + Description string + Metadata entities.Metadata + // Optional. A retry with the same key returns the first result without moving + // anything again. + IdempotencyKey string +} + +// WalletLotInput is one lot a credit creates. +type WalletLotInput struct { + Amount int64 + // Nil means the lot never expires. + ExpiresAt *time.Time + // The lot this one was carried over from, for transfers, exchanges and refunds. + OriginLotID *uuid.UUID +} + +type WalletCreditInput struct { + WalletEntry + // How the credit is split into lots. Their amounts must add up to Amount. Leave + // empty for a single lot that never expires. + Lots []WalletLotInput +} + +type WalletDebitInput struct { + WalletEntry + // Lots to draw from first, in this order, before falling back to the K9 order. + // A reversal names the lots its EARN created (F10), and the expiry job names the + // lot that expired. These lots are used even if they have already expired. + PreferredLotIDs []uuid.UUID +} + +// WalletAllocation is how much a debit took from one lot. It carries the lot's +// expiry, so a transfer or exchange can give the receiving lot the same expiry (K9). +type WalletAllocation struct { + LotID uuid.UUID + Amount int64 + ExpiresAt *time.Time +} + +type WalletResult struct { + // Nil only when DebitUpTo found nothing to take. + Transaction *entities.WalletTransaction + // The lots a credit created. + Lots []entities.WalletLot + // The lots a debit drew from, in the order they were used. + Allocations []WalletAllocation + // What DebitUpTo could not take because the balance ran out. + Shortfall int64 + // True when the idempotency key had already been used and nothing moved. + Replayed bool +} + +// CarryOver turns a debit's allocations into lots for the receiving side of a +// transfer or exchange. Each lot keeps the expiry of the lot it came from and points +// back at it, so a balance cannot be kept alive by moving it around (K9). +func (r *WalletResult) CarryOver() []WalletLotInput { + lots := make([]WalletLotInput, 0, len(r.Allocations)) + for _, a := range r.Allocations { + lotID := a.LotID + lots = append(lots, WalletLotInput{Amount: a.Amount, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID}) + } + return lots +} + +// WalletProcessor is the only code allowed to change a wallet balance. Every change +// writes the balance, the ledger row and the lots or allocations together, which is +// what keeps SUM(ledger) = balance = SUM(lot remaining) (§7.5). +// +// Every method must run inside a transaction from TxManager, and the repository +// refuses otherwise. Each method locks the customer's wallet itself, so a single-wallet +// caller needs nothing more. A caller touching two wallets, such as a transfer, must +// call LockWallets first so the locks are always taken in the same order. +type WalletProcessor struct { + repo repository.WalletRepository + now func() time.Time +} + +func NewWalletProcessor(repo repository.WalletRepository) *WalletProcessor { + return &WalletProcessor{repo: repo, now: time.Now} +} + +// LockWallet locks one customer's wallet, creating it if needed. Credit and Debit do +// this themselves; call it when something must be read under the lock first. +func (p *WalletProcessor) LockWallet(ctx context.Context, customerID uuid.UUID) error { + _, err := p.repo.LockWallet(ctx, customerID) + return err +} + +// LockWallets locks two customers' wallets in a fixed order. Call it before touching +// both wallets in one transaction. +func (p *WalletProcessor) LockWallets(ctx context.Context, a, b uuid.UUID) error { + _, _, err := p.repo.LockWallets(ctx, a, b) + return err +} + +// Credit adds Amount to the wallet and creates its lots. +func (p *WalletProcessor) Credit(ctx context.Context, in WalletCreditInput) (*WalletResult, error) { + if err := validateWalletEntry(&in.WalletEntry, true); err != nil { + return nil, err + } + lots := in.Lots + if len(lots) == 0 { + lots = []WalletLotInput{{Amount: in.Amount}} + } + var total int64 + for _, lot := range lots { + if lot.Amount <= 0 { + return nil, fmt.Errorf("%w: lot amount must be positive, got %d", ErrWalletInvalidEntry, lot.Amount) + } + total += lot.Amount + } + if total != in.Amount { + return nil, fmt.Errorf("%w: lots add up to %d, not %d", ErrWalletInvalidEntry, total, in.Amount) + } + + wallet, err := p.repo.LockWallet(ctx, in.CustomerID) + if err != nil { + return nil, err + } + if replay, err := p.replay(ctx, &in.WalletEntry, true, true); replay != nil || err != nil { + return replay, err + } + + balance, err := p.repo.AddBalance(ctx, in.CustomerID, in.Currency, in.Amount) + if err != nil { + return nil, err + } + walletTx := newWalletTransaction(wallet, &in.WalletEntry, in.Amount, balance, nil) + if err := p.repo.CreateTransaction(ctx, walletTx); err != nil { + return nil, fmt.Errorf("failed to create wallet transaction: %w", err) + } + + result := &WalletResult{Transaction: walletTx} + for _, lotIn := range lots { + lot := entities.WalletLot{ + OrganizationID: wallet.OrganizationID, + CustomerID: in.CustomerID, + Currency: in.Currency, + SourceTransactionID: walletTx.ID, + OriginLotID: lotIn.OriginLotID, + OriginalAmount: lotIn.Amount, + RemainingAmount: lotIn.Amount, + ExpiresAt: lotIn.ExpiresAt, + } + if err := p.repo.CreateLot(ctx, &lot); err != nil { + return nil, fmt.Errorf("failed to create wallet lot: %w", err) + } + result.Lots = append(result.Lots, lot) + } + return result, nil +} + +// Debit takes exactly Amount from the wallet, or nothing at all with +// repository.ErrWalletInsufficientBalance if the usable balance is short. +func (p *WalletProcessor) Debit(ctx context.Context, in WalletDebitInput) (*WalletResult, error) { + return p.debit(ctx, in, false) +} + +// DebitUpTo takes as much of Amount as the wallet has and reports the rest as +// Shortfall. It is for reversing earnings the customer has already spent (F10, Q3). +// When there is nothing to take, no ledger row is written and Transaction is nil; +// such a call leaves no trace, so a retry with the same key takes whatever the +// balance holds by then. +func (p *WalletProcessor) DebitUpTo(ctx context.Context, in WalletDebitInput) (*WalletResult, error) { + return p.debit(ctx, in, true) +} + +func (p *WalletProcessor) debit(ctx context.Context, in WalletDebitInput, upTo bool) (*WalletResult, error) { + if err := validateWalletEntry(&in.WalletEntry, false); err != nil { + return nil, err + } + wallet, err := p.repo.LockWallet(ctx, in.CustomerID) + if err != nil { + return nil, err + } + // DebitUpTo may have taken less than asked, so the amount cannot be compared. + if replay, err := p.replay(ctx, &in.WalletEntry, false, !upTo); replay != nil || err != nil { + return replay, err + } + + lots, err := p.spendableLots(ctx, &in) + if err != nil { + return nil, err + } + + var available int64 + for _, lot := range lots { + available += lot.RemainingAmount + } + take := in.Amount + if available < take { + if !upTo { + return nil, repository.ErrWalletInsufficientBalance + } + take = available + } + result := &WalletResult{Shortfall: in.Amount - take} + if take == 0 { + return result, nil + } + + var metadata entities.Metadata + if upTo { + metadata = entities.Metadata{"requested_amount": in.Amount, "shortfall": result.Shortfall} + } + + balance, err := p.repo.AddBalance(ctx, in.CustomerID, in.Currency, -take) + if err != nil { + return nil, err + } + walletTx := newWalletTransaction(wallet, &in.WalletEntry, -take, balance, metadata) + if err := p.repo.CreateTransaction(ctx, walletTx); err != nil { + return nil, fmt.Errorf("failed to create wallet transaction: %w", err) + } + result.Transaction = walletTx + + var allocations []entities.WalletLotAllocation + remaining := take + for _, lot := range lots { + if remaining == 0 { + break + } + amount := min(lot.RemainingAmount, remaining) + remaining -= amount + if err := p.repo.ConsumeLot(ctx, lot.ID, amount); err != nil { + return nil, err + } + allocations = append(allocations, entities.WalletLotAllocation{TransactionID: walletTx.ID, LotID: lot.ID, Amount: amount}) + result.Allocations = append(result.Allocations, WalletAllocation{LotID: lot.ID, Amount: amount, ExpiresAt: lot.ExpiresAt}) + } + if err := p.repo.CreateAllocations(ctx, allocations); err != nil { + return nil, fmt.Errorf("failed to create wallet lot allocations: %w", err) + } + return result, nil +} + +// spendableLots returns the lots a debit may draw from, in the order it draws: the +// preferred lots first, then the unexpired lots in K9 order. +func (p *WalletProcessor) spendableLots(ctx context.Context, in *WalletDebitInput) ([]entities.WalletLot, error) { + var lots []entities.WalletLot + preferred := make(map[uuid.UUID]bool, len(in.PreferredLotIDs)) + + if len(in.PreferredLotIDs) > 0 { + found, err := p.repo.GetLotsByIDs(ctx, in.PreferredLotIDs) + if err != nil { + return nil, err + } + byID := make(map[uuid.UUID]entities.WalletLot, len(found)) + for _, lot := range found { + byID[lot.ID] = lot + } + for _, id := range in.PreferredLotIDs { + lot, ok := byID[id] + if !ok || lot.CustomerID != in.CustomerID || lot.Currency != in.Currency { + return nil, fmt.Errorf("%w: lot %s is not a %s lot of this customer", ErrWalletInvalidEntry, id, in.Currency) + } + if preferred[id] { + continue + } + preferred[id] = true + if lot.RemainingAmount > 0 { + lots = append(lots, lot) + } + } + } + + active, err := p.repo.ListActiveLots(ctx, in.CustomerID, in.Currency, p.now()) + if err != nil { + return nil, err + } + for _, lot := range active { + if !preferred[lot.ID] { + lots = append(lots, lot) + } + } + return lots, nil +} + +// replay returns the first result for an idempotency key that has already been used, +// or nil when the key is new. It runs after the wallet lock, so a concurrent request +// with the same key has either committed its row or not started. +func (p *WalletProcessor) replay(ctx context.Context, in *WalletEntry, credit, compareAmount bool) (*WalletResult, error) { + if in.IdempotencyKey == "" { + return nil, nil + } + walletTx, err := p.repo.GetTransactionByIdempotencyKey(ctx, in.IdempotencyKey) + if err != nil || walletTx == nil { + return nil, err + } + + sameDirection := (walletTx.Amount > 0) == credit + sameAmount := !compareAmount || abs(walletTx.Amount) == in.Amount + if walletTx.CustomerID != in.CustomerID || walletTx.Currency != in.Currency || + walletTx.Type != in.Type || !sameDirection || !sameAmount { + return nil, ErrWalletIdempotencyConflict + } + + result := &WalletResult{Transaction: walletTx, Replayed: true} + if credit { + result.Lots, err = p.repo.ListLotsBySourceTransaction(ctx, walletTx.ID) + return result, err + } + + // JSON numbers come back from JSONB as float64. + switch shortfall := walletTx.Metadata["shortfall"].(type) { + case float64: + result.Shortfall = int64(shortfall) + case int64: + result.Shortfall = shortfall + } + allocations, err := p.repo.ListAllocationsByTransaction(ctx, walletTx.ID) + if err != nil { + return nil, err + } + ids := make([]uuid.UUID, 0, len(allocations)) + for _, a := range allocations { + ids = append(ids, a.LotID) + } + lots, err := p.repo.GetLotsByIDs(ctx, ids) + if err != nil { + return nil, err + } + expiry := make(map[uuid.UUID]*time.Time, len(lots)) + for _, lot := range lots { + expiry[lot.ID] = lot.ExpiresAt + } + for _, a := range allocations { + result.Allocations = append(result.Allocations, WalletAllocation{LotID: a.LotID, Amount: a.Amount, ExpiresAt: expiry[a.LotID]}) + } + return result, nil +} + +func newWalletTransaction(wallet *entities.CustomerWallet, in *WalletEntry, amount, balance int64, extra entities.Metadata) *entities.WalletTransaction { + metadata := entities.Metadata{} + for k, v := range in.Metadata { + metadata[k] = v + } + for k, v := range extra { + metadata[k] = v + } + var key *string + if in.IdempotencyKey != "" { + k := in.IdempotencyKey + key = &k + } + return &entities.WalletTransaction{ + ID: in.TransactionID, + OrganizationID: wallet.OrganizationID, + CustomerID: in.CustomerID, + Currency: in.Currency, + Type: in.Type, + Amount: amount, + BalanceAfter: balance, + GroupID: in.GroupID, + ReferenceType: in.ReferenceType, + ReferenceID: in.ReferenceID, + CounterpartyCustomerID: in.CounterpartyCustomerID, + ReversesTransactionID: in.ReversesTransactionID, + OutletID: in.OutletID, + CreatedByUser: in.CreatedByUser, + Reason: in.Reason, + Description: in.Description, + Metadata: metadata, + IdempotencyKey: key, + } +} + +// walletTypeRule is one row of §8.1. +type walletTypeRule struct { + credit, debit bool + currency string // empty: either currency + referenceTypes []string + needsOutlet bool + needsReverses bool + needsGroup bool + needsCounter bool + needsActor bool +} + +var walletTypeRules = map[string]walletTypeRule{ + constants.WalletTxTypeEarn: {credit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true}, + constants.WalletTxTypeEarnReversal: {debit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true, needsReverses: true}, + constants.WalletTxTypePayment: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true}, + constants.WalletTxTypePaymentRefund: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true, needsReverses: true}, + constants.WalletTxTypeExchangeOut: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true}, + constants.WalletTxTypeExchangeIn: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true}, + constants.WalletTxTypeTransferOut: {debit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true}, + constants.WalletTxTypeTransferIn: {credit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true}, + constants.WalletTxTypeGameSpend: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGamePlay}}, + constants.WalletTxTypeExpire: {debit: true, referenceTypes: []string{constants.WalletRefTypeLot}}, + constants.WalletTxTypeAdjustment: {credit: true, debit: true, referenceTypes: []string{constants.WalletRefTypeUser}, needsActor: true}, + constants.WalletTxTypeMigration: {credit: true, referenceTypes: []string{constants.WalletRefTypeLegacyPoints, constants.WalletRefTypeLegacyTokens}}, + constants.WalletTxTypeRewardRedeem: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeRewardRedemption}}, +} + +func validateWalletEntry(in *WalletEntry, credit bool) error { + invalid := func(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrWalletInvalidEntry, fmt.Sprintf(format, args...)) + } + + rule, ok := walletTypeRules[in.Type] + if !ok { + return invalid("unknown type %q", in.Type) + } + if credit && !rule.credit { + return invalid("%s cannot add to a balance", in.Type) + } + if !credit && !rule.debit { + return invalid("%s cannot take from a balance", in.Type) + } + if in.CustomerID == uuid.Nil { + return invalid("customer is required") + } + if !constants.IsValidWalletCurrency(in.Currency) { + return invalid("unknown currency %q", in.Currency) + } + if rule.currency != "" && in.Currency != rule.currency { + return invalid("%s must be in %s", in.Type, rule.currency) + } + if in.Amount <= 0 { + return invalid("amount must be positive, got %d", in.Amount) + } + if !containsString(rule.referenceTypes, in.ReferenceType) { + return invalid("%s must reference %s, got %q", in.Type, strings.Join(rule.referenceTypes, " or "), in.ReferenceType) + } + if in.ReferenceID == uuid.Nil { + return invalid("reference id is required") + } + if strings.TrimSpace(in.Description) == "" { + return invalid("description is required") + } + if rule.needsOutlet && isNilID(in.OutletID) { + return invalid("%s requires an outlet", in.Type) + } + if rule.needsReverses && isNilID(in.ReversesTransactionID) { + return invalid("%s requires the transaction it reverses", in.Type) + } + if rule.needsGroup && isNilID(in.GroupID) { + return invalid("%s requires a group id", in.Type) + } + if rule.needsCounter { + if isNilID(in.CounterpartyCustomerID) { + return invalid("%s requires a counterparty", in.Type) + } + if *in.CounterpartyCustomerID == in.CustomerID { + return invalid("%s cannot go to the same customer", in.Type) + } + } + if rule.needsActor { + if isNilID(in.CreatedByUser) { + return invalid("%s requires the admin who made it", in.Type) + } + if in.Reason == nil || strings.TrimSpace(*in.Reason) == "" { + return invalid("%s requires a reason", in.Type) + } + } + return nil +} + +func isNilID(id *uuid.UUID) bool { + return id == nil || *id == uuid.Nil +} + +func containsString(values []string, v string) bool { + for _, value := range values { + if value == v { + return true + } + } + return false +} + +func abs(v int64) int64 { + if v < 0 { + return -v + } + return v +} diff --git a/internal/processor/wallet_processor_db_test.go b/internal/processor/wallet_processor_db_test.go new file mode 100644 index 0000000..d3aa795 --- /dev/null +++ b/internal/processor/wallet_processor_db_test.go @@ -0,0 +1,142 @@ +package processor + +import ( + "context" + "os" + "testing" + "time" + + "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" +) + +// Runs the engine against Postgres, to show the rows it writes pass the database +// constraints and reconcile the way §7.5 requires. Needs TEST_DATABASE_URL pointing +// at a migrated database; see internal/repository/wallet_repository_test.go. +func TestWalletProcessor_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) + + org, a, b := uuid.New(), uuid.New(), uuid.New() + require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet test', 'basic')`, org).Error) + require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, a, org, b, org).Error) + customers := []uuid.UUID{a, b} + 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) + }) + + p := NewWalletProcessor(repository.NewWalletRepository(db)) + txm := repository.NewTxManager(db) + now := time.Now() + inTx := func(fn func(ctx context.Context) error) { + t.Helper() + require.NoError(t, txm.WithTransaction(context.Background(), fn)) + } + + // Without a transaction nothing moves. + _, err = p.Credit(context.Background(), earn(a, 10, nil)) + assert.ErrorIs(t, err, repository.ErrWalletTxRequired) + + var earned *WalletResult + inTx(func(ctx context.Context) error { + soon := now.Add(time.Hour) + earned, err = p.Credit(ctx, earn(a, 100, &soon)) + require.NoError(t, err) + _, err = p.Credit(ctx, earn(a, 50, nil)) + return err + }) + + // Transfer 120 from A to B, spanning both of A's lots. + inTx(func(ctx context.Context) error { + require.NoError(t, p.LockWallets(ctx, a, b)) + group, outID, inID := uuid.New(), uuid.New(), uuid.New() + out, err := p.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ + TransactionID: outID, CustomerID: a, Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypeTransferOut, Amount: 120, + ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: inID, + GroupID: &group, CounterpartyCustomerID: &b, Description: "Transfer ke B", + }}) + require.NoError(t, err) + _, err = p.Credit(ctx, WalletCreditInput{ + WalletEntry: WalletEntry{ + TransactionID: inID, CustomerID: b, Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypeTransferIn, Amount: 120, + ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: outID, + GroupID: &group, CounterpartyCustomerID: &a, Description: "Transfer dari A", + }, + Lots: out.CarryOver(), + }) + return err + }) + + // Reversing the 100 earned leaves A 30 short. The retry reads the shortfall back + // out of JSONB and takes nothing more. + rev := reversal(a, 100, earned) + rev.IdempotencyKey = "reverse:" + earned.Transaction.ID.String() + var first, second *WalletResult + inTx(func(ctx context.Context) error { + first, err = p.DebitUpTo(ctx, rev) + return err + }) + inTx(func(ctx context.Context) error { + second, err = p.DebitUpTo(ctx, rev) + return err + }) + assert.Equal(t, int64(-30), first.Transaction.Amount) + assert.Equal(t, int64(70), first.Shortfall) + assert.True(t, second.Replayed) + assert.Equal(t, int64(70), second.Shortfall) + assert.Equal(t, first.Transaction.ID, second.Transaction.ID) + + // Overdraw fails and rolls back cleanly. + err = txm.WithTransaction(context.Background(), func(ctx context.Context) error { + _, err := p.Debit(ctx, pay(b, 121)) + return err + }) + assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance) + + var balances []struct { + CustomerID uuid.UUID + PointBalance int64 + } + require.NoError(t, db.Raw(`SELECT customer_id, point_balance FROM customer_wallets WHERE customer_id IN ?`, customers).Scan(&balances).Error) + got := map[uuid.UUID]int64{} + for _, row := range balances { + got[row.CustomerID] = row.PointBalance + } + assert.Equal(t, map[uuid.UUID]int64{a: 0, b: 120}, got) + + // §7.5, straight from the tables. + var broken []string + require.NoError(t, db.Raw(` + SELECT 'wallet ' || w.customer_id 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.point_balance <> (SELECT COALESCE(SUM(remaining_amount), 0) FROM wallet_lots l WHERE l.customer_id = w.customer_id AND l.currency = 'POINT')) + UNION ALL + SELECT 'lot ' || l.id FROM wallet_lots l + WHERE l.customer_id IN ? AND l.original_amount - l.remaining_amount + <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_lot_allocations a WHERE a.lot_id = l.id) + UNION ALL + SELECT 'debit ' || t.id FROM wallet_transactions t + WHERE t.customer_id IN ? AND t.amount < 0 + AND -t.amount <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_lot_allocations a WHERE a.transaction_id = t.id)`, + customers, customers, customers).Scan(&broken).Error) + assert.Empty(t, broken) +} diff --git a/internal/processor/wallet_processor_test.go b/internal/processor/wallet_processor_test.go new file mode 100644 index 0000000..810dfd7 --- /dev/null +++ b/internal/processor/wallet_processor_test.go @@ -0,0 +1,788 @@ +package processor + +import ( + "context" + "errors" + "sort" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/repository" +) + +// walletRepoFake is an in-memory WalletRepository with the same conditional-update +// semantics as the real one. Locks are only counted: these tests are single-threaded, +// and the locking itself is covered by the repository tests against Postgres. +type walletRepoFake struct { + customers map[uuid.UUID]uuid.UUID // customer -> organization + wallets map[uuid.UUID]*entities.CustomerWallet + transactions []*entities.WalletTransaction + lots []*entities.WalletLot + allocations []entities.WalletLotAllocation + locks map[uuid.UUID]int + clock time.Time +} + +func newWalletRepoFake() *walletRepoFake { + return &walletRepoFake{ + customers: map[uuid.UUID]uuid.UUID{}, + wallets: map[uuid.UUID]*entities.CustomerWallet{}, + locks: map[uuid.UUID]int{}, + clock: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } +} + +func (f *walletRepoFake) tick() time.Time { + f.clock = f.clock.Add(time.Second) + return f.clock +} + +func (f *walletRepoFake) LockWallet(_ context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) { + org, ok := f.customers[customerID] + if !ok { + return nil, repository.ErrWalletNotFound + } + if f.wallets[customerID] == nil { + f.wallets[customerID] = &entities.CustomerWallet{CustomerID: customerID, OrganizationID: org} + } + f.locks[customerID]++ + w := *f.wallets[customerID] + return &w, nil +} + +func (f *walletRepoFake) LockWallets(ctx context.Context, a, b uuid.UUID) (*entities.CustomerWallet, *entities.CustomerWallet, error) { + wa, err := f.LockWallet(ctx, a) + if err != nil { + return nil, nil, err + } + wb, err := f.LockWallet(ctx, b) + return wa, wb, err +} + +func (f *walletRepoFake) AddBalance(_ context.Context, customerID uuid.UUID, currency string, delta int64) (int64, error) { + w := f.wallets[customerID] + if w == nil { + return 0, repository.ErrWalletNotFound + } + balance := &w.PointBalance + if currency == constants.WalletCurrencyCoin { + balance = &w.CoinBalance + } + if *balance+delta < 0 { + return 0, repository.ErrWalletInsufficientBalance + } + *balance += delta + return *balance, nil +} + +func (f *walletRepoFake) GetWallet(_ context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) { + w := f.wallets[customerID] + if w == nil { + return nil, errors.New("not found") + } + c := *w + return &c, nil +} + +func (f *walletRepoFake) CreateTransaction(_ context.Context, tx *entities.WalletTransaction) error { + if tx.IdempotencyKey != nil { + for _, t := range f.transactions { + if t.IdempotencyKey != nil && *t.IdempotencyKey == *tx.IdempotencyKey { + return errors.New("duplicate idempotency key") + } + } + } + if tx.ID == uuid.Nil { + tx.ID = uuid.New() + } + tx.CreatedAt = f.tick() + c := *tx + f.transactions = append(f.transactions, &c) + return nil +} + +func (f *walletRepoFake) GetTransactionByIdempotencyKey(_ context.Context, key string) (*entities.WalletTransaction, error) { + for _, t := range f.transactions { + if t.IdempotencyKey != nil && *t.IdempotencyKey == key { + c := *t + return &c, nil + } + } + return nil, nil +} + +func (f *walletRepoFake) CreateLot(_ context.Context, lot *entities.WalletLot) error { + if lot.ID == uuid.Nil { + lot.ID = uuid.New() + } + lot.CreatedAt = f.tick() + c := *lot + f.lots = append(f.lots, &c) + return nil +} + +func (f *walletRepoFake) GetLotsByIDs(_ context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) { + var out []entities.WalletLot + for _, lot := range f.lots { + for _, id := range ids { + if lot.ID == id { + out = append(out, *lot) + break + } + } + } + return out, nil +} + +func (f *walletRepoFake) ListLotsBySourceTransaction(_ context.Context, txID uuid.UUID) ([]entities.WalletLot, error) { + var out []entities.WalletLot + for _, lot := range f.lots { + if lot.SourceTransactionID == txID { + out = append(out, *lot) + } + } + return out, nil +} + +func (f *walletRepoFake) ListActiveLots(_ context.Context, customerID uuid.UUID, currency string, asOf time.Time) ([]entities.WalletLot, error) { + var out []entities.WalletLot + for _, lot := range f.lots { + if lot.CustomerID == customerID && lot.Currency == currency && lot.RemainingAmount > 0 && + (lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf)) { + out = append(out, *lot) + } + } + sort.SliceStable(out, func(i, j int) bool { + a, b := out[i], out[j] + switch { + case a.ExpiresAt == nil && b.ExpiresAt != nil: + return false + case a.ExpiresAt != nil && b.ExpiresAt == nil: + return true + case a.ExpiresAt != nil && !a.ExpiresAt.Equal(*b.ExpiresAt): + return a.ExpiresAt.Before(*b.ExpiresAt) + } + return a.CreatedAt.Before(b.CreatedAt) + }) + return out, nil +} + +func (f *walletRepoFake) ConsumeLot(_ context.Context, lotID uuid.UUID, amount int64) error { + for _, lot := range f.lots { + if lot.ID == lotID { + if lot.RemainingAmount < amount { + return repository.ErrWalletLotInsufficient + } + lot.RemainingAmount -= amount + return nil + } + } + return repository.ErrWalletLotInsufficient +} + +func (f *walletRepoFake) CreateAllocations(_ context.Context, allocations []entities.WalletLotAllocation) error { + f.allocations = append(f.allocations, allocations...) + return nil +} + +func (f *walletRepoFake) ListAllocationsByTransaction(_ context.Context, txID uuid.UUID) ([]entities.WalletLotAllocation, error) { + var out []entities.WalletLotAllocation + for _, a := range f.allocations { + if a.TransactionID == txID { + out = append(out, a) + } + } + return out, nil +} + +// assertInvariants checks the reconciliation rules of §7.5 over everything the fake +// holds. +func (f *walletRepoFake) assertInvariants(t *testing.T) { + t.Helper() + allocatedFromLot := map[uuid.UUID]int64{} + allocatedByTx := map[uuid.UUID]int64{} + for _, a := range f.allocations { + allocatedFromLot[a.LotID] += a.Amount + allocatedByTx[a.TransactionID] += a.Amount + } + createdByTx := map[uuid.UUID]int64{} + for _, lot := range f.lots { + createdByTx[lot.SourceTransactionID] += lot.OriginalAmount + assert.Equal(t, lot.OriginalAmount-allocatedFromLot[lot.ID], lot.RemainingAmount, "lot %s: original - allocations = remaining", lot.ID) + } + for _, tx := range f.transactions { + if tx.Amount > 0 { + assert.Equal(t, tx.Amount, createdByTx[tx.ID], "credit %s: lots add up to the amount", tx.Type) + assert.Zero(t, allocatedByTx[tx.ID], "credit %s has no allocations", tx.Type) + } else { + assert.Equal(t, -tx.Amount, allocatedByTx[tx.ID], "debit %s: allocations add up to the amount", tx.Type) + assert.Zero(t, createdByTx[tx.ID], "debit %s creates no lots", tx.Type) + } + } + for customerID, w := range f.wallets { + for currency, balance := range map[string]int64{ + constants.WalletCurrencyPoint: w.PointBalance, + constants.WalletCurrencyCoin: w.CoinBalance, + } { + var ledger, lots, last int64 + for _, tx := range f.transactions { + if tx.CustomerID == customerID && tx.Currency == currency { + ledger += tx.Amount + last = tx.BalanceAfter + } + } + for _, lot := range f.lots { + if lot.CustomerID == customerID && lot.Currency == currency { + lots += lot.RemainingAmount + } + } + assert.Equal(t, balance, ledger, "%s balance = SUM(ledger)", currency) + assert.Equal(t, balance, lots, "%s balance = SUM(lot remaining)", currency) + assert.Equal(t, balance, last, "%s balance = last balance_after", currency) + } + } +} + +type walletTestEnv struct { + repo *walletRepoFake + p *WalletProcessor + now time.Time + org uuid.UUID + ctx context.Context +} + +func newWalletTestEnv(t *testing.T) *walletTestEnv { + repo := newWalletRepoFake() + env := &walletTestEnv{ + repo: repo, + p: NewWalletProcessor(repo), + now: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), + org: uuid.New(), + ctx: context.Background(), + } + env.p.now = func() time.Time { return env.now } + t.Cleanup(func() { repo.assertInvariants(t) }) + return env +} + +func (e *walletTestEnv) customer() uuid.UUID { + id := uuid.New() + e.repo.customers[id] = e.org + return id +} + +func (e *walletTestEnv) at(d time.Duration) *time.Time { + v := e.now.Add(d) + return &v +} + +func ptr[T any](v T) *T { return &v } + +func earn(customerID uuid.UUID, amount int64, expiresAt *time.Time) WalletCreditInput { + return WalletCreditInput{ + WalletEntry: WalletEntry{ + CustomerID: customerID, + Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypeEarn, + Amount: amount, + ReferenceType: constants.WalletRefTypeOrder, + ReferenceID: uuid.New(), + OutletID: ptr(uuid.New()), + Description: "Belanja", + }, + Lots: []WalletLotInput{{Amount: amount, ExpiresAt: expiresAt}}, + } +} + +func pay(customerID uuid.UUID, amount int64) WalletDebitInput { + return WalletDebitInput{WalletEntry: WalletEntry{ + CustomerID: customerID, + Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypePayment, + Amount: amount, + ReferenceType: constants.WalletRefTypePayment, + ReferenceID: uuid.New(), + OutletID: ptr(uuid.New()), + Description: "Bayar", + }} +} + +func (e *walletTestEnv) credit(t *testing.T, in WalletCreditInput) *WalletResult { + t.Helper() + res, err := e.p.Credit(e.ctx, in) + require.NoError(t, err) + return res +} + +func (e *walletTestEnv) balance(t *testing.T, customerID uuid.UUID) int64 { + t.Helper() + w, err := e.repo.GetWallet(e.ctx, customerID) + require.NoError(t, err) + return w.PointBalance +} + +func allocationsOf(res *WalletResult) map[uuid.UUID]int64 { + out := map[uuid.UUID]int64{} + for _, a := range res.Allocations { + out[a.LotID] = a.Amount + } + return out +} + +func TestWalletProcessor_CreditCreatesLedgerRowAndLot(t *testing.T) { + e := newWalletTestEnv(t) + c := e.customer() + + res := e.credit(t, earn(c, 100, e.at(24*time.Hour))) + + assert.Equal(t, int64(100), res.Transaction.Amount) + assert.Equal(t, int64(100), res.Transaction.BalanceAfter) + assert.Equal(t, e.org, res.Transaction.OrganizationID, "organization comes from the wallet") + require.Len(t, res.Lots, 1) + assert.Equal(t, res.Transaction.ID, res.Lots[0].SourceTransactionID) + assert.Equal(t, e.at(24*time.Hour), res.Lots[0].ExpiresAt) + assert.Equal(t, int64(100), e.balance(t, c)) + assert.Equal(t, 1, e.repo.locks[c], "credit locks the wallet itself") +} + +func TestWalletProcessor_CreditWithoutLotsMakesOneNonExpiringLot(t *testing.T) { + e := newWalletTestEnv(t) + c := e.customer() + in := earn(c, 40, nil) + in.Lots = nil + + res := e.credit(t, in) + require.Len(t, res.Lots, 1) + assert.Equal(t, int64(40), res.Lots[0].OriginalAmount) + assert.Nil(t, res.Lots[0].ExpiresAt) +} + +func TestWalletProcessor_CreditRejectsLotsThatDoNotAddUp(t *testing.T) { + e := newWalletTestEnv(t) + c := e.customer() + in := earn(c, 100, nil) + in.Lots = []WalletLotInput{{Amount: 60}, {Amount: 30}} + + _, err := e.p.Credit(e.ctx, in) + assert.ErrorIs(t, err, ErrWalletInvalidEntry) + + in.Lots = []WalletLotInput{{Amount: 100}, {Amount: 0}} + _, err = e.p.Credit(e.ctx, in) + assert.ErrorIs(t, err, ErrWalletInvalidEntry) + assert.Empty(t, e.repo.transactions) +} + +func TestWalletProcessor_DebitAcrossSeveralLots(t *testing.T) { + e := newWalletTestEnv(t) + c := e.customer() + first := e.credit(t, earn(c, 30, e.at(1*time.Hour))).Lots[0] + second := e.credit(t, earn(c, 50, e.at(2*time.Hour))).Lots[0] + third := e.credit(t, earn(c, 40, e.at(3*time.Hour))).Lots[0] + + res, err := e.p.Debit(e.ctx, pay(c, 70)) + require.NoError(t, err) + + assert.Equal(t, int64(-70), res.Transaction.Amount) + assert.Equal(t, int64(50), res.Transaction.BalanceAfter) + assert.Equal(t, map[uuid.UUID]int64{first.ID: 30, second.ID: 40}, allocationsOf(res)) + assert.Equal(t, first.ID, res.Allocations[0].LotID, "allocations are reported in the order used") + assert.Equal(t, e.at(1*time.Hour), res.Allocations[0].ExpiresAt) + + lots, _ := e.repo.GetLotsByIDs(e.ctx, []uuid.UUID{first.ID, second.ID, third.ID}) + remaining := map[uuid.UUID]int64{} + for _, l := range lots { + remaining[l.ID] = l.RemainingAmount + } + assert.Equal(t, map[uuid.UUID]int64{first.ID: 0, second.ID: 10, third.ID: 40}, remaining) +} + +// K9: soonest expiry first, lots without an expiry last and oldest first among them, +// expired lots never. +func TestWalletProcessor_DebitFollowsLotOrder(t *testing.T) { + e := newWalletTestEnv(t) + c := e.customer() + neverOld := e.credit(t, earn(c, 10, nil)).Lots[0] + late := e.credit(t, earn(c, 10, e.at(48*time.Hour))).Lots[0] + soon := e.credit(t, earn(c, 10, e.at(1*time.Hour))).Lots[0] + neverNew := e.credit(t, earn(c, 10, nil)).Lots[0] + e.credit(t, earn(c, 10, e.at(-1*time.Hour))) // already expired + + var order []uuid.UUID + for i := 0; i < 4; i++ { + res, err := e.p.Debit(e.ctx, pay(c, 10)) + require.NoError(t, err) + require.Len(t, res.Allocations, 1) + order = append(order, res.Allocations[0].LotID) + } + assert.Equal(t, []uuid.UUID{soon.ID, late.ID, neverOld.ID, neverNew.ID}, order) + + // The expired lot still counts in the balance until the expiry job removes it, + // but it cannot be spent (§7.3). + assert.Equal(t, int64(10), e.balance(t, c)) + _, err := e.p.Debit(e.ctx, pay(c, 10)) + assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance) +} + +func TestWalletProcessor_DebitOverBalanceChangesNothing(t *testing.T) { + e := newWalletTestEnv(t) + c := e.customer() + e.credit(t, earn(c, 50, nil)) + + _, err := e.p.Debit(e.ctx, pay(c, 51)) + assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance) + assert.Equal(t, int64(50), e.balance(t, c)) + assert.Len(t, e.repo.transactions, 1) + assert.Empty(t, e.repo.allocations) + + // A customer who never had a wallet has nothing to spend. + _, err = e.p.Debit(e.ctx, pay(e.customer(), 1)) + assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance) +} + +func reversal(customerID uuid.UUID, amount int64, earnRes *WalletResult) WalletDebitInput { + in := WalletDebitInput{WalletEntry: WalletEntry{ + CustomerID: customerID, + Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypeEarnReversal, + Amount: amount, + ReferenceType: constants.WalletRefTypeOrder, + ReferenceID: earnRes.Transaction.ReferenceID, + ReversesTransactionID: &earnRes.Transaction.ID, + OutletID: earnRes.Transaction.OutletID, + Description: "Batal", + }} + for _, lot := range earnRes.Lots { + in.PreferredLotIDs = append(in.PreferredLotIDs, lot.ID) + } + return in +} + +func TestWalletProcessor_DebitUpToWithShortfall(t *testing.T) { + e := newWalletTestEnv(t) + c := e.customer() + earned := e.credit(t, earn(c, 100, nil)) + _, err := e.p.Debit(e.ctx, pay(c, 70)) + require.NoError(t, err) + + res, err := e.p.DebitUpTo(e.ctx, reversal(c, 100, earned)) + require.NoError(t, err) + assert.Equal(t, int64(-30), res.Transaction.Amount) + assert.Equal(t, int64(70), res.Shortfall) + assert.Equal(t, int64(100), res.Transaction.Metadata["requested_amount"]) + assert.Equal(t, int64(70), res.Transaction.Metadata["shortfall"]) + assert.Equal(t, int64(0), e.balance(t, c)) + + // Nothing left: no ledger row, the whole amount is shortfall. + in := reversal(c, 5, earned) + res, err = e.p.DebitUpTo(e.ctx, in) + require.NoError(t, err) + assert.Nil(t, res.Transaction) + assert.Equal(t, int64(5), res.Shortfall) + assert.Len(t, e.repo.transactions, 3) +} + +// A reversal draws from the lots its EARN created first (F10), even when an older lot +// would come first in K9 order, and even when that lot has expired. +func TestWalletProcessor_DebitDrawsPreferredLotsFirst(t *testing.T) { + e := newWalletTestEnv(t) + c := e.customer() + older := e.credit(t, earn(c, 50, e.at(1*time.Hour))).Lots[0] + earned := e.credit(t, earn(c, 20, e.at(-1*time.Hour))) + + res, err := e.p.Debit(e.ctx, reversal(c, 30, earned)) + require.NoError(t, err) + require.Len(t, res.Allocations, 2) + assert.Equal(t, earned.Lots[0].ID, res.Allocations[0].LotID) + assert.Equal(t, int64(20), res.Allocations[0].Amount) + assert.Equal(t, older.ID, res.Allocations[1].LotID) + assert.Equal(t, int64(10), res.Allocations[1].Amount) +} + +func TestWalletProcessor_DebitRejectsSomeoneElsesLot(t *testing.T) { + e := newWalletTestEnv(t) + a, b := e.customer(), e.customer() + e.credit(t, earn(a, 10, nil)) + other := e.credit(t, earn(b, 10, nil)) + + in := pay(a, 5) + in.PreferredLotIDs = []uuid.UUID{other.Lots[0].ID} + _, err := e.p.Debit(e.ctx, in) + assert.ErrorIs(t, err, ErrWalletInvalidEntry) + + in.PreferredLotIDs = []uuid.UUID{uuid.New()} + _, err = e.p.Debit(e.ctx, in) + assert.ErrorIs(t, err, ErrWalletInvalidEntry) +} + +func TestWalletProcessor_ExpireDrawsTheExpiredLot(t *testing.T) { + e := newWalletTestEnv(t) + c := e.customer() + e.credit(t, earn(c, 10, nil)) + expired := e.credit(t, earn(c, 25, e.at(-1*time.Hour))).Lots[0] + + res, err := e.p.Debit(e.ctx, WalletDebitInput{ + WalletEntry: WalletEntry{ + CustomerID: c, + Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypeExpire, + Amount: expired.RemainingAmount, + ReferenceType: constants.WalletRefTypeLot, + ReferenceID: expired.ID, + Description: "Kedaluwarsa", + IdempotencyKey: "expire:" + expired.ID.String(), + }, + PreferredLotIDs: []uuid.UUID{expired.ID}, + }) + require.NoError(t, err) + assert.Equal(t, map[uuid.UUID]int64{expired.ID: 25}, allocationsOf(res)) + assert.Equal(t, int64(10), e.balance(t, c)) +} + +func TestWalletProcessor_IdempotentCredit(t *testing.T) { + e := newWalletTestEnv(t) + c := e.customer() + in := earn(c, 100, nil) + in.IdempotencyKey = "earn:order-1" + + first := e.credit(t, in) + in.ReferenceID = first.Transaction.ReferenceID + second := e.credit(t, in) + + assert.True(t, second.Replayed) + assert.False(t, first.Replayed) + assert.Equal(t, first.Transaction.ID, second.Transaction.ID) + assert.Equal(t, first.Lots[0].ID, second.Lots[0].ID) + assert.Equal(t, int64(100), e.balance(t, c)) + assert.Len(t, e.repo.transactions, 1) +} + +func TestWalletProcessor_IdempotentDebit(t *testing.T) { + e := newWalletTestEnv(t) + c := e.customer() + e.credit(t, earn(c, 30, e.at(time.Hour))) + e.credit(t, earn(c, 30, nil)) + in := pay(c, 40) + in.IdempotencyKey = "pay:1" + + first, err := e.p.Debit(e.ctx, in) + require.NoError(t, err) + second, err := e.p.Debit(e.ctx, in) + require.NoError(t, err) + + assert.True(t, second.Replayed) + assert.Equal(t, first.Transaction.ID, second.Transaction.ID) + assert.ElementsMatch(t, first.Allocations, second.Allocations) + assert.Equal(t, int64(20), e.balance(t, c)) +} + +func TestWalletProcessor_IdempotentDebitUpToKeepsShortfall(t *testing.T) { + e := newWalletTestEnv(t) + c := e.customer() + earned := e.credit(t, earn(c, 100, nil)) + _, err := e.p.Debit(e.ctx, pay(c, 60)) + require.NoError(t, err) + in := reversal(c, 100, earned) + in.IdempotencyKey = "reverse:order-1" + + first, err := e.p.DebitUpTo(e.ctx, in) + require.NoError(t, err) + e.credit(t, earn(c, 500, nil)) // new balance must not be taken by the retry + second, err := e.p.DebitUpTo(e.ctx, in) + require.NoError(t, err) + + assert.True(t, second.Replayed) + assert.Equal(t, first.Transaction.ID, second.Transaction.ID) + assert.Equal(t, int64(60), second.Shortfall) + assert.Equal(t, int64(500), e.balance(t, c)) +} + +func TestWalletProcessor_IdempotencyKeyReusedForAnotherOperation(t *testing.T) { + e := newWalletTestEnv(t) + c := e.customer() + in := earn(c, 100, nil) + in.IdempotencyKey = "k" + e.credit(t, in) + + other := earn(c, 99, nil) + other.IdempotencyKey = "k" + _, err := e.p.Credit(e.ctx, other) + assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) + + debit := pay(c, 100) + debit.IdempotencyKey = "k" + _, err = e.p.Debit(e.ctx, debit) + assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) + + otherCustomer := earn(e.customer(), 100, nil) + otherCustomer.IdempotencyKey = "k" + _, err = e.p.Credit(e.ctx, otherCustomer) + assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) +} + +// A transfer debits the sender and credits the receiver with lots that keep the +// sender's expiry (K9), following the example in §8. +func TestWalletProcessor_TransferCarriesExpiry(t *testing.T) { + e := newWalletTestEnv(t) + a, b := e.customer(), e.customer() + dec := e.credit(t, earn(a, 100, e.at(30*24*time.Hour))).Lots[0] + jan := e.credit(t, earn(a, 50, e.at(60*24*time.Hour))).Lots[0] + + require.NoError(t, e.p.LockWallets(e.ctx, a, b)) + group, outID, inID := uuid.New(), uuid.New(), uuid.New() + out, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: WalletEntry{ + TransactionID: outID, CustomerID: a, Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypeTransferOut, Amount: 120, + ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: inID, + GroupID: &group, CounterpartyCustomerID: &b, Description: "Transfer ke B", + }}) + require.NoError(t, err) + received, err := e.p.Credit(e.ctx, WalletCreditInput{ + WalletEntry: WalletEntry{ + TransactionID: inID, CustomerID: b, Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypeTransferIn, Amount: 120, + ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: outID, + GroupID: &group, CounterpartyCustomerID: &a, Description: "Transfer dari A", + }, + Lots: out.CarryOver(), + }) + require.NoError(t, err) + + assert.Equal(t, outID, out.Transaction.ID) + assert.Equal(t, inID, received.Transaction.ID) + require.Len(t, received.Lots, 2) + assert.Equal(t, int64(100), received.Lots[0].OriginalAmount) + assert.Equal(t, dec.ExpiresAt, received.Lots[0].ExpiresAt) + assert.Equal(t, &dec.ID, received.Lots[0].OriginLotID) + assert.Equal(t, int64(20), received.Lots[1].OriginalAmount) + assert.Equal(t, jan.ExpiresAt, received.Lots[1].ExpiresAt) + assert.Equal(t, &jan.ID, received.Lots[1].OriginLotID) + assert.Equal(t, int64(30), e.balance(t, a)) + assert.Equal(t, int64(120), e.balance(t, b)) +} + +func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) { + c := uuid.New() + outlet := ptr(uuid.New()) + + credits := map[string]func(*WalletCreditInput){ + "unknown type": func(in *WalletCreditInput) { in.Type = "BONUS" }, + "debit-only type as credit": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypePayment + in.ReferenceType = constants.WalletRefTypePayment + }, + "unknown currency": func(in *WalletCreditInput) { in.Currency = "GOLD" }, + "zero amount": func(in *WalletCreditInput) { in.Amount = 0; in.Lots = nil }, + "negative amount": func(in *WalletCreditInput) { in.Amount = -5; in.Lots = nil }, + "wrong reference type": func(in *WalletCreditInput) { in.ReferenceType = constants.WalletRefTypeUser }, + "missing reference id": func(in *WalletCreditInput) { in.ReferenceID = uuid.Nil }, + "missing description": func(in *WalletCreditInput) { in.Description = " " }, + "EARN without outlet": func(in *WalletCreditInput) { in.OutletID = nil }, + "missing customer": func(in *WalletCreditInput) { in.CustomerID = uuid.Nil }, + "EXCHANGE_IN in COIN": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeExchangeIn + in.Currency = constants.WalletCurrencyCoin + in.ReferenceType = constants.WalletRefTypeWalletTx + in.GroupID = ptr(uuid.New()) + }, + "TRANSFER_IN to self": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeTransferIn + in.ReferenceType = constants.WalletRefTypeWalletTx + in.GroupID = ptr(uuid.New()) + in.CounterpartyCustomerID = &in.CustomerID + }, + "TRANSFER_IN without group": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeTransferIn + in.ReferenceType = constants.WalletRefTypeWalletTx + in.CounterpartyCustomerID = ptr(uuid.New()) + }, + "PAYMENT_REFUND without source": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypePaymentRefund + in.ReferenceType = constants.WalletRefTypePayment + }, + "ADJUSTMENT without reason": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeAdjustment + in.ReferenceType = constants.WalletRefTypeUser + in.CreatedByUser = ptr(uuid.New()) + }, + "ADJUSTMENT blank reason": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeAdjustment + in.ReferenceType = constants.WalletRefTypeUser + in.CreatedByUser = ptr(uuid.New()) + in.Reason = ptr(" ") + }, + "ADJUSTMENT without admin": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeAdjustment + in.ReferenceType = constants.WalletRefTypeUser + in.Reason = ptr("komplain") + }, + "MIGRATION wrong reference": func(in *WalletCreditInput) { + in.Type = constants.WalletTxTypeMigration + in.ReferenceType = constants.WalletRefTypeOrder + }, + } + for name, mutate := range credits { + t.Run("credit/"+name, func(t *testing.T) { + e := newWalletTestEnv(t) + e.repo.customers[c] = e.org + in := earn(c, 10, nil) + in.OutletID = outlet + mutate(&in) + _, err := e.p.Credit(e.ctx, in) + assert.ErrorIs(t, err, ErrWalletInvalidEntry) + assert.Empty(t, e.repo.transactions) + }) + } + + debits := map[string]func(*WalletDebitInput){ + "credit-only type as debit": func(in *WalletDebitInput) { + in.Type = constants.WalletTxTypeMigration + in.ReferenceType = constants.WalletRefTypeLegacyPoints + }, + "PAYMENT in COIN": func(in *WalletDebitInput) { in.Currency = constants.WalletCurrencyCoin }, + "GAME_SPEND in POINT": func(in *WalletDebitInput) { + in.Type = constants.WalletTxTypeGameSpend + in.ReferenceType = constants.WalletRefTypeGamePlay + }, + "EXPIRE not pointing at a lot": func(in *WalletDebitInput) { + in.Type = constants.WalletTxTypeExpire + in.ReferenceType = constants.WalletRefTypeOrder + }, + "EARN_REVERSAL without source": func(in *WalletDebitInput) { + in.Type = constants.WalletTxTypeEarnReversal + in.ReferenceType = constants.WalletRefTypeOrder + }, + "TRANSFER_OUT without counterparty": func(in *WalletDebitInput) { + in.Type = constants.WalletTxTypeTransferOut + in.ReferenceType = constants.WalletRefTypeWalletTx + in.GroupID = ptr(uuid.New()) + }, + "REWARD_REDEEM wrong reference": func(in *WalletDebitInput) { + in.Type = constants.WalletTxTypeRewardRedeem + in.ReferenceType = constants.WalletRefTypeOrder + }, + } + for name, mutate := range debits { + t.Run("debit/"+name, func(t *testing.T) { + e := newWalletTestEnv(t) + e.repo.customers[c] = e.org + e.credit(t, earn(c, 100, nil)) + in := pay(c, 10) + mutate(&in) + _, err := e.p.Debit(e.ctx, in) + assert.ErrorIs(t, err, ErrWalletInvalidEntry) + assert.Len(t, e.repo.transactions, 1) + }) + } +} + +func TestWalletProcessor_UnknownCustomer(t *testing.T) { + e := newWalletTestEnv(t) + _, err := e.p.Credit(e.ctx, earn(uuid.New(), 10, nil)) + assert.ErrorIs(t, err, repository.ErrWalletNotFound) +} diff --git a/internal/repository/wallet_repository.go b/internal/repository/wallet_repository.go index b33f761..a88e269 100644 --- a/internal/repository/wallet_repository.go +++ b/internal/repository/wallet_repository.go @@ -52,6 +52,11 @@ type WalletRepository interface { GetTransactionByIdempotencyKey(ctx context.Context, key string) (*entities.WalletTransaction, error) CreateLot(ctx context.Context, lot *entities.WalletLot) error + // GetLotsByIDs returns the lots with the given ids, expired or not, in no + // particular order. Ids that match no lot are left out. + GetLotsByIDs(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) + // ListLotsBySourceTransaction returns the lots a credit created, oldest first. + ListLotsBySourceTransaction(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error) // ListActiveLots returns the lots that still have balance and have not expired at // asOf, in the order they are spent (K9): soonest expiry first, lots without an // expiry last, oldest first within the same expiry. @@ -204,6 +209,32 @@ func (r *walletRepository) CreateLot(ctx context.Context, lot *entities.WalletLo return db.Create(lot).Error } +func (r *walletRepository) GetLotsByIDs(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) { + var lots []entities.WalletLot + if len(ids) == 0 { + return lots, nil + } + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("id IN ?", ids). + Find(&lots).Error + if err != nil { + return nil, fmt.Errorf("failed to get wallet lots: %w", err) + } + return lots, nil +} + +func (r *walletRepository) ListLotsBySourceTransaction(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error) { + var lots []entities.WalletLot + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("source_transaction_id = ?", transactionID). + Order("created_at, id"). + Find(&lots).Error + if err != nil { + return nil, fmt.Errorf("failed to list wallet lots by source transaction: %w", err) + } + return lots, nil +} + func (r *walletRepository) ListActiveLots(ctx context.Context, customerID uuid.UUID, currency string, asOf time.Time) ([]entities.WalletLot, error) { var lots []entities.WalletLot // Filter and order match idx_wallet_lots_consume. -- 2.54.0 From 6af97f569606e16702cfe3e9ebbd534be883920e Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 08:47:37 +0700 Subject: [PATCH 15/47] fix(wallet): stop logging new idempotency keys as errors GetTransactionByIdempotencyKey used First, so every wallet operation with a key not seen before, which is the normal case, logged a "record not found" error. It now uses Find with a limit and returns nil when nothing matches. Co-Authored-By: Claude Opus 5.5 --- internal/repository/wallet_repository.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/internal/repository/wallet_repository.go b/internal/repository/wallet_repository.go index a88e269..be3763b 100644 --- a/internal/repository/wallet_repository.go +++ b/internal/repository/wallet_repository.go @@ -188,17 +188,20 @@ func (r *walletRepository) CreateTransaction(ctx context.Context, walletTx *enti } func (r *walletRepository) GetTransactionByIdempotencyKey(ctx context.Context, key string) (*entities.WalletTransaction, error) { - var walletTx entities.WalletTransaction + // Find rather than First: a new key is the normal case, and First would log every + // one of them as a "record not found" error. + var walletTxs []entities.WalletTransaction err := DBFromContext(ctx, r.db).WithContext(ctx). Where("idempotency_key = ?", key). - First(&walletTx).Error + Limit(1). + Find(&walletTxs).Error if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } return nil, fmt.Errorf("failed to get wallet transaction by idempotency key: %w", err) } - return &walletTx, nil + if len(walletTxs) == 0 { + return nil, nil + } + return &walletTxs[0], nil } func (r *walletRepository) CreateLot(ctx context.Context, lot *entities.WalletLot) error { -- 2.54.0 From 41b75810fd66b950674b7e584a7d5910a29b0d87 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 08:47:48 +0700 Subject: [PATCH 16/47] feat(wallet): migrate legacy points and tokens into the wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 8 + cmd/wallet-migrate/main.go | 73 +++++++ internal/contract/campaign_contract.go | 10 +- internal/entities/campaign.go | 25 ++- internal/entities/campaign_test.go | 18 ++ internal/mappers/campaign_mapper.go | 8 +- internal/models/campaign.go | 10 +- internal/processor/campaign_processor.go | 8 +- .../processor/wallet_migration_processor.go | 184 ++++++++++++++++++ .../wallet_migration_processor_db_test.go | 167 ++++++++++++++++ .../repository/wallet_migration_repository.go | 134 +++++++++++++ internal/service/campaign_service.go | 14 +- internal/validator/campaign_validator.go | 16 +- ...2_rename_campaign_tokens_to_coins.down.sql | 5 + ...092_rename_campaign_tokens_to_coins.up.sql | 7 + 15 files changed, 652 insertions(+), 35 deletions(-) create mode 100644 cmd/wallet-migrate/main.go create mode 100644 internal/entities/campaign_test.go create mode 100644 internal/processor/wallet_migration_processor.go create mode 100644 internal/processor/wallet_migration_processor_db_test.go create mode 100644 internal/repository/wallet_migration_repository.go create mode 100644 migrations/000092_rename_campaign_tokens_to_coins.down.sql create mode 100644 migrations/000092_rename_campaign_tokens_to_coins.up.sql diff --git a/Makefile b/Makefile index 6b79f01..48f2b97 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,7 @@ help: @echo " migration-up Up migrations" @echo " migration-up ENV=production Up migrations (production DB)" @echo " migration-down Down last migration" + @echo " wallet-migrate args=-dry-run Move legacy points/tokens into the wallet" @echo @echo " docker-up Up docker services" @echo " docker-down Down docker services" @@ -105,6 +106,13 @@ migration-down: migration-force: @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 seeder-create: @migrate create -ext sql -dir ./seeders -seq $(name) diff --git a/cmd/wallet-migrate/main.go b/cmd/wallet-migrate/main.go new file mode 100644 index 0000000..7cd2652 --- /dev/null +++ b/cmd/wallet-migrate/main.go @@ -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") +} diff --git a/internal/contract/campaign_contract.go b/internal/contract/campaign_contract.go index bb5e479..f19d4a2 100644 --- a/internal/contract/campaign_contract.go +++ b/internal/contract/campaign_contract.go @@ -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"` diff --git a/internal/entities/campaign.go b/internal/entities/campaign.go index bf76ac5..5eabd76 100644 --- a/internal/entities/campaign.go +++ b/internal/entities/campaign.go @@ -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" ) diff --git a/internal/entities/campaign_test.go b/internal/entities/campaign_test.go new file mode 100644 index 0000000..5dd1cd0 --- /dev/null +++ b/internal/entities/campaign_test.go @@ -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("")) +} diff --git a/internal/mappers/campaign_mapper.go b/internal/mappers/campaign_mapper.go index f662108..ffd2940 100644 --- a/internal/mappers/campaign_mapper.go +++ b/internal/mappers/campaign_mapper.go @@ -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, diff --git a/internal/models/campaign.go b/internal/models/campaign.go index 9c65dc6..9720694 100644 --- a/internal/models/campaign.go +++ b/internal/models/campaign.go @@ -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"` diff --git a/internal/processor/campaign_processor.go b/internal/processor/campaign_processor.go index 2823a26..57f2c8f 100644 --- a/internal/processor/campaign_processor.go +++ b/internal/processor/campaign_processor.go @@ -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, diff --git a/internal/processor/wallet_migration_processor.go b/internal/processor/wallet_migration_processor.go new file mode 100644 index 0000000..16aaef6 --- /dev/null +++ b/internal/processor/wallet_migration_processor.go @@ -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 +} diff --git a/internal/processor/wallet_migration_processor_db_test.go b/internal/processor/wallet_migration_processor_db_test.go new file mode 100644 index 0000000..c0c3f3d --- /dev/null +++ b/internal/processor/wallet_migration_processor_db_test.go @@ -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 +} diff --git a/internal/repository/wallet_migration_repository.go b/internal/repository/wallet_migration_repository.go new file mode 100644 index 0000000..cfef523 --- /dev/null +++ b/internal/repository/wallet_migration_repository.go @@ -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 +} diff --git a/internal/service/campaign_service.go b/internal/service/campaign_service.go index 95a671a..bc42ba2 100644 --- a/internal/service/campaign_service.go +++ b/internal/service/campaign_service.go @@ -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) } diff --git a/internal/validator/campaign_validator.go b/internal/validator/campaign_validator.go index d46bae7..8618fc8 100644 --- a/internal/validator/campaign_validator.go +++ b/internal/validator/campaign_validator.go @@ -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))) } diff --git a/migrations/000092_rename_campaign_tokens_to_coins.down.sql b/migrations/000092_rename_campaign_tokens_to_coins.down.sql new file mode 100644 index 0000000..06a8ab1 --- /dev/null +++ b/migrations/000092_rename_campaign_tokens_to_coins.down.sql @@ -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'; diff --git a/migrations/000092_rename_campaign_tokens_to_coins.up.sql b/migrations/000092_rename_campaign_tokens_to_coins.up.sql new file mode 100644 index 0000000..8ccf3fc --- /dev/null +++ b/migrations/000092_rename_campaign_tokens_to_coins.up.sql @@ -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'; -- 2.54.0 From e5db0325ccabbfa118deb6121aac4dcc546341eb Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 09:15:26 +0700 Subject: [PATCH 17/47] feat(wallet): serve customer balances and history from the wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /customer/wallet now reads the EnakPoint & EnakCoin wallet (docs/prd-point-coin.md F6, PC-106): spendable point and coin balances, the rupiah value of one EnakPoint and of the balance, the nearest day each currency loses balance (grouped by Asia/Jakarta day), and recent ledger rows. The fields of the pre-wallet response stay, filled from the wallet, so app versions that read them keep working. Adds GET /customer/wallet/transactions with pagination and filters for currency, one or more types, and an inclusive date range. Each row shows where the value came from (additions) or went to (deductions) as in §8.1, and additions list their lots and earliest expiry. The counterparty id, the admin and the metadata are left out; the description already carries the masked name. A malformed query answers 400, a missing customer 404. /customer/points and /customer/tokens keep their shape and now read the wallet too, so customer_points_repository is no longer used for balances. Balances are what the customer can spend: lots that have expired but that the expiry job has not processed are not counted. The point value is read from organization_settings (loyalty.point.value, default 1) through a small repository that the typed settings reader in PC-109 will build on. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 6 +- internal/constants/wallet.go | 7 + internal/handler/customer_points_handler.go | 59 +++- internal/handler/customer_wallet_db_test.go | 218 ++++++++++++ internal/models/customer_points.go | 13 + internal/models/wallet.go | 71 ++++ .../processor/customer_points_processor.go | 166 +++------ internal/processor/wallet_query_processor.go | 323 ++++++++++++++++++ .../processor/wallet_query_processor_test.go | 246 +++++++++++++ .../organization_setting_repository.go | 41 +++ .../repository/wallet_query_repository.go | 170 +++++++++ internal/router/router.go | 1 + internal/service/customer_points_service.go | 8 + 13 files changed, 1199 insertions(+), 130 deletions(-) create mode 100644 internal/handler/customer_wallet_db_test.go create mode 100644 internal/models/wallet.go create mode 100644 internal/processor/wallet_query_processor.go create mode 100644 internal/processor/wallet_query_processor_test.go create mode 100644 internal/repository/organization_setting_repository.go create mode 100644 internal/repository/wallet_query_repository.go diff --git a/internal/app/app.go b/internal/app/app.go index 932aeb4..1183350 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -248,6 +248,8 @@ type repositories struct { expenseRepo *repository.ExpenseRepositoryImpl cashAdvanceRepo *repository.CashAdvanceRepositoryImpl walletRepo repository.WalletRepository + walletQueryRepo repository.WalletQueryRepository + organizationSettingRepo repository.OrganizationSettingRepository } func (a *App) initRepositories() *repositories { @@ -304,6 +306,8 @@ func (a *App) initRepositories() *repositories { expenseRepo: repository.NewExpenseRepositoryImpl(a.db), cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db), walletRepo: repository.NewWalletRepository(a.db), + walletQueryRepo: repository.NewWalletQueryRepository(a.db), + organizationSettingRepo: repository.NewOrganizationSettingRepository(a.db), } } @@ -397,7 +401,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo), customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()), - customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo, repos.gameRepo), + customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, repos.organizationSettingRepo), repos.gameRepo), otpProcessor: otpProcessor, fileClient: fileClient, inventoryMovementService: inventoryMovementService, diff --git a/internal/constants/wallet.go b/internal/constants/wallet.go index 2892fdd..6182154 100644 --- a/internal/constants/wallet.go +++ b/internal/constants/wallet.go @@ -42,3 +42,10 @@ const ( WalletRefTypeLegacyTokens = "LEGACY_TOKENS" WalletRefTypeRewardRedemption = "REWARD_REDEMPTION" ) + +// Organization loyalty settings (docs/prd-point-coin.md F2), stored in +// organization_settings. A key that was never set takes its default. +const ( + LoyaltyPointValueKey = "loyalty.point.value" + LoyaltyPointValueDefault = int64(1) +) diff --git a/internal/handler/customer_points_handler.go b/internal/handler/customer_points_handler.go index 8e98a26..818d914 100644 --- a/internal/handler/customer_points_handler.go +++ b/internal/handler/customer_points_handler.go @@ -1,9 +1,14 @@ package handler import ( + "errors" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" "apskel-pos-be/internal/service" "apskel-pos-be/internal/util" @@ -46,7 +51,7 @@ func (h *CustomerPointsHandler) GetCustomerPoints(c *gin.Context) { if err != nil { logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerPoints -> service call failed") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()), }), "CustomerPointsHandler::GetCustomerPoints") return } @@ -80,7 +85,7 @@ func (h *CustomerPointsHandler) GetCustomerTokens(c *gin.Context) { if err != nil { logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerTokens -> service call failed") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()), }), "CustomerPointsHandler::GetCustomerTokens") return } @@ -114,7 +119,7 @@ func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) { if err != nil { logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWallet -> service call failed") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()), }), "CustomerPointsHandler::GetCustomerWallet") return } @@ -151,3 +156,51 @@ func (h *CustomerPointsHandler) GetFerrisWheelGame(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetFerrisWheelGame") } + +// GetCustomerWalletTransactions is GET /customer/wallet/transactions: the ledger, newest +// first, filterable by currency, type and date (docs/prd-point-coin.md F6). +func (h *CustomerPointsHandler) GetCustomerWalletTransactions(c *gin.Context) { + ctx := c.Request.Context() + + customerID, ok := c.Get("customer_id") + customerIDStr, isString := customerID.(string) + if !ok || !isString { + logger.FromContext(ctx).Error("Customer ID not found in context") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"), + }), "CustomerPointsHandler::GetCustomerWalletTransactions") + return + } + + var query models.ListCustomerWalletTransactionsQuery + if err := c.ShouldBindQuery(&query); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()), + }), "CustomerPointsHandler::GetCustomerWalletTransactions") + return + } + + response, err := h.customerPointsService.GetCustomerWalletTransactions(ctx, customerIDStr, query) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWalletTransactions -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()), + }), "CustomerPointsHandler::GetCustomerWalletTransactions") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWalletTransactions") +} + +// walletErrorCode tells a bad request apart from a server failure on the wallet +// endpoints. +func walletErrorCode(err error) string { + switch { + case errors.Is(err, processor.ErrInvalidWalletQuery): + return constants.ValidationErrorCode + case errors.Is(err, repository.ErrWalletNotFound): + return constants.NotFoundErrorCode + default: + return constants.InternalServerErrorCode + } +} diff --git a/internal/handler/customer_wallet_db_test.go b/internal/handler/customer_wallet_db_test.go new file mode 100644 index 0000000..e3713b2 --- /dev/null +++ b/internal/handler/customer_wallet_db_test.go @@ -0,0 +1,218 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/gin-gonic/gin" + "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" + applogger "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" + "apskel-pos-be/internal/service" +) + +// Drives the customer wallet endpoints over HTTP down to Postgres, to show the app +// gets the right numbers from the new endpoints and from the old /points, /tokens +// and /wallet shapes. Needs TEST_DATABASE_URL pointing at a migrated database; see +// internal/repository/wallet_repository_test.go. +func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + applogger.Setup("fatal", "json") // the handlers log their errors; the server sets this up at startup + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + + org, customer, other := uuid.New(), uuid.New(), uuid.New() + customers := []uuid.UUID{customer, other} + 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 (?, 'wallet api test', 'basic')`, org) + exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, customer, org, other, org) + exec(`INSERT INTO organization_settings (organization_id, key, value) VALUES (?, ?, '100')`, org, constants.LoyaltyPointValueKey) + 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 organization_settings WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM organizations WHERE id = ?`, org) + }) + + // Balance: 300 earned in two lots that expire on the same Jakarta day but on + // different UTC days, 8 coins, 50 paid, and 20 in a lot that has expired but + // that the expiry job has not processed. + wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) + txm := repository.NewTxManager(db) + next := time.Now().Year() + 1 + lateNightUTC := time.Date(next, 7, 1, 17, 30, 0, 0, time.UTC) // 2 July 00:30 WIB + afternoonUTC := time.Date(next, 7, 2, 10, 0, 0, 0, time.UTC) // 2 July 17:00 WIB + expired := time.Now().Add(-time.Hour) + outlet, order, payment := uuid.New(), uuid.New(), uuid.New() + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + if _, err := wallet.Credit(ctx, processor.WalletCreditInput{ + WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypeEarn, Amount: 300, ReferenceType: constants.WalletRefTypeOrder, + ReferenceID: order, OutletID: &outlet, Description: "Belanja #ORD-1"}, + Lots: []processor.WalletLotInput{{Amount: 200, ExpiresAt: &afternoonUTC}, {Amount: 100, ExpiresAt: &lateNightUTC}}, + }); err != nil { + return err + } + if _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ + CustomerID: customer, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration, + Amount: 8, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: customer, + Description: "Saldo awal dari sistem lama"}}); err != nil { + return err + } + if _, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{ + CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, + Amount: 50, ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, OutletID: &outlet, + Description: "Bayar #ORD-2"}}); err != nil { + return err + } + _, err := wallet.Credit(ctx, processor.WalletCreditInput{ + WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypeEarn, Amount: 20, ReferenceType: constants.WalletRefTypeOrder, + ReferenceID: uuid.New(), OutletID: &outlet, Description: "Belanja #ORD-0"}, + Lots: []processor.WalletLotInput{{Amount: 20, ExpiresAt: &expired}}, + }) + return err + })) + + h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor( + processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), repository.NewOrganizationSettingRepository(db)), + nil, + ))) + gin.SetMode(gin.TestMode) + router := gin.New() + as := func(id uuid.UUID) gin.HandlerFunc { + return func(c *gin.Context) { c.Set("customer_id", id.String()) } + } + for _, who := range []struct { + prefix string + id uuid.UUID + }{{"/a", customer}, {"/b", other}, {"/gone", uuid.New()}} { + g := router.Group(who.prefix, as(who.id)) + g.GET("/points", h.GetCustomerPoints) + g.GET("/tokens", h.GetCustomerTokens) + g.GET("/wallet", h.GetCustomerWallet) + g.GET("/wallet/transactions", h.GetCustomerWalletTransactions) + } + get := func(path string) (int, map[string]any) { + t.Helper() + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), rec.Body.String()) + return rec.Code, body + } + // The balance endpoints wrap their data in {status, message, data}. + inner := func(body map[string]any) map[string]any { + return body["data"].(map[string]any)["data"].(map[string]any) + } + + // Old app: /points and /tokens keep their shape and show the spendable balance. + status, body := get("/a/points") + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 250, inner(body)["total_points"], "300 - 50; the expired 20 is not spendable") + assert.Len(t, inner(body)["points_history"], 3) + + status, body = get("/a/tokens") + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 8, inner(body)["total_tokens"]) + + // /wallet: new fields, with the old ones still there. + status, body = get("/a/wallet") + require.Equal(t, http.StatusOK, status, body) + w := inner(body) + assert.EqualValues(t, 250, w["point_balance"]) + assert.EqualValues(t, 8, w["coin_balance"]) + assert.EqualValues(t, 100, w["point_value"]) + assert.EqualValues(t, 25000, w["point_discount_value"]) + nearest := w["nearest_expiring"].(map[string]any) + // The payment took 50 from the lot expiring first, leaving 50 + 200. + assert.Equal(t, map[string]any{"amount": float64(250), "date": time.Date(next, 7, 2, 0, 0, 0, 0, time.UTC).Format("2006-01-02")}, + nearest["point"], "both lots fall on 2 July in Jakarta although they are on different UTC days") + assert.Nil(t, nearest["coin"]) + for _, legacy := range []string{"total_points", "total_tokens", "points_history", "tokens_history", "last_updated"} { + assert.Contains(t, w, legacy) + } + assert.EqualValues(t, 250, w["total_points"]) + + recent := w["recent_transactions"].([]any) + require.Len(t, recent, 4) + for _, raw := range recent { + tx := raw.(map[string]any) + assert.NotContains(t, tx, "counterparty_customer_id") + assert.NotContains(t, tx, "metadata") + if tx["amount"].(float64) > 0 { + assert.Contains(t, tx, "source", tx["type"]) + assert.NotContains(t, tx, "destination") + } else { + assert.Equal(t, map[string]any{"type": "PAYMENT", "id": payment.String()}, tx["destination"]) + assert.NotContains(t, tx, "source") + } + } + + // History with filters. + status, body = get("/a/wallet/transactions?currency=POINT&type=EARN&limit=1&page=2") + require.Equal(t, http.StatusOK, status, body) + page := body["data"].(map[string]any) + assert.Equal(t, map[string]any{"page": float64(2), "limit": float64(1), "total_count": float64(2), "total_pages": float64(2)}, page["pagination"]) + rows := page["data"].([]any) + require.Len(t, rows, 1) + first := rows[0].(map[string]any) + assert.Equal(t, "Belanja #ORD-1", first["description"], "newest first, so page 2 is the older earning") + assert.Equal(t, map[string]any{"type": "ORDER", "id": order.String()}, first["source"]) + assert.Equal(t, lateNightUTC.Format(time.RFC3339), parseTime(t, first["expires_at"]).UTC().Format(time.RFC3339)) + assert.Len(t, first["lots"], 2) + + today := time.Now().In(time.FixedZone("WIB", 7*3600)).Format("2006-01-02") + status, body = get("/a/wallet/transactions?from=" + today + "&to=" + today + "&type=PAYMENT,MIGRATION") + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 2, body["data"].(map[string]any)["pagination"].(map[string]any)["total_count"]) + + status, _ = get("/a/wallet/transactions?currency=GOLD") + assert.Equal(t, http.StatusBadRequest, status) + status, _ = get("/a/wallet/transactions?limit=abc") + assert.Equal(t, http.StatusBadRequest, status) + + // A customer who has never had a wallet sees zeros, not an error. + status, body = get("/b/wallet") + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 0, inner(body)["point_balance"]) + assert.Equal(t, []any{}, inner(body)["recent_transactions"]) + status, body = get("/b/wallet/transactions") + require.Equal(t, http.StatusOK, status, body) + assert.Equal(t, []any{}, body["data"].(map[string]any)["data"]) + + // A token for a customer that no longer exists. + status, _ = get("/gone/wallet") + assert.Equal(t, http.StatusNotFound, status) +} + +func parseTime(t *testing.T, v any) time.Time { + t.Helper() + s, ok := v.(string) + require.True(t, ok, "expected a time string, got %v", v) + parsed, err := time.Parse(time.RFC3339Nano, s) + require.NoError(t, err) + return parsed +} diff --git a/internal/models/customer_points.go b/internal/models/customer_points.go index cfe9b45..c4b4ca9 100644 --- a/internal/models/customer_points.go +++ b/internal/models/customer_points.go @@ -109,7 +109,20 @@ type GetCustomerWalletResponse struct { Data *GetCustomerWalletResponseData `json:"data,omitempty"` } +// GetCustomerWalletResponseData is GET /customer/wallet (docs/prd-point-coin.md F6). type GetCustomerWalletResponseData struct { + // Balances the customer can spend now. + PointBalance int64 `json:"point_balance"` + CoinBalance int64 `json:"coin_balance"` + // Rupiah value of one EnakPoint, and of the whole point balance. EnakPoint only + // ever pays for orders, so the app shows this as "setara potongan Rp …" (K7). + PointValue int64 `json:"point_value"` + PointDiscountValue int64 `json:"point_discount_value"` + NearestExpiring CustomerWalletNearestExpiring `json:"nearest_expiring"` + RecentTransactions []CustomerWalletTransaction `json:"recent_transactions"` + + // Deprecated: the shape of this endpoint before the wallet, kept for app versions + // that still read it. Filled from the wallet like the fields above. TotalPoints int64 `json:"total_points"` TotalTokens int64 `json:"total_tokens"` PointsHistory []PointsHistoryItem `json:"points_history,omitempty"` diff --git a/internal/models/wallet.go b/internal/models/wallet.go new file mode 100644 index 0000000..27f20bc --- /dev/null +++ b/internal/models/wallet.go @@ -0,0 +1,71 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// CustomerWalletTransaction is one ledger row as the customer app shows it +// (docs/prd-point-coin.md F6). +type CustomerWalletTransaction struct { + ID uuid.UUID `json:"id"` + Currency string `json:"currency"` + Type string `json:"type"` + // Signed: positive added to the balance, negative taken from it. + Amount int64 `json:"amount"` + BalanceAfter int64 `json:"balance_after"` + Description string `json:"description"` + // Where the value came from, set on additions. + Source *CustomerWalletTransactionRef `json:"source,omitempty"` + // Where the value went, set on deductions. + Destination *CustomerWalletTransactionRef `json:"destination,omitempty"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + ReversesTransactionID *uuid.UUID `json:"reverses_transaction_id,omitempty"` + // Shared by the two rows of an exchange or a transfer. + GroupID *uuid.UUID `json:"group_id,omitempty"` + // Additions only: the earliest expiry among the lots it created, nil when none of + // them expire, and the lots themselves. + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Lots []CustomerWalletLot `json:"lots,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// CustomerWalletTransactionRef points at what a ledger row came from or went to, as +// listed in §8.1: ORDER, PAYMENT, WALLET_TX, GAME_PLAY, LOT, USER and so on. +type CustomerWalletTransactionRef struct { + Type string `json:"type"` + ID uuid.UUID `json:"id"` +} + +type CustomerWalletLot struct { + Amount int64 `json:"amount"` + Remaining int64 `json:"remaining"` + ExpiresAt *time.Time `json:"expires_at"` +} + +// CustomerWalletExpiring is how much expires on one day. +type CustomerWalletExpiring struct { + Amount int64 `json:"amount"` + // YYYY-MM-DD, Asia/Jakarta. + Date string `json:"date"` +} + +// CustomerWalletNearestExpiring is the next day each currency loses balance, nil when +// nothing is due to expire. +type CustomerWalletNearestExpiring struct { + Point *CustomerWalletExpiring `json:"point"` + Coin *CustomerWalletExpiring `json:"coin"` +} + +// ListCustomerWalletTransactionsQuery is GET /customer/wallet/transactions. +type ListCustomerWalletTransactionsQuery struct { + Page int `form:"page"` + Limit int `form:"limit"` + Currency string `form:"currency"` + // One type, or several separated by commas. + Type string `form:"type"` + // Inclusive calendar dates, YYYY-MM-DD, Asia/Jakarta. + From string `form:"from"` + To string `form:"to"` +} diff --git a/internal/processor/customer_points_processor.go b/internal/processor/customer_points_processor.go index d65ef2b..020c705 100644 --- a/internal/processor/customer_points_processor.go +++ b/internal/processor/customer_points_processor.go @@ -3,7 +3,6 @@ package processor import ( "context" "fmt" - "time" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" @@ -12,14 +11,14 @@ import ( ) type CustomerPointsProcessor struct { - customerPointsRepo repository.CustomerPointsRepository - gameRepo *repository.GameRepository + walletQuery *WalletQueryProcessor + gameRepo *repository.GameRepository } -func NewCustomerPointsProcessor(customerPointsRepo repository.CustomerPointsRepository, gameRepo *repository.GameRepository) *CustomerPointsProcessor { +func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *repository.GameRepository) *CustomerPointsProcessor { return &CustomerPointsProcessor{ - customerPointsRepo: customerPointsRepo, - gameRepo: gameRepo, + walletQuery: walletQuery, + gameRepo: gameRepo, } } @@ -70,159 +69,74 @@ func (p *CustomerPointsProcessor) DeductPoints(ctx context.Context, customerID u return nil, fmt.Errorf("not implemented") } +// The three endpoints below are the customer app's balance screens. They read the +// EnakPoint & EnakCoin wallet; customer_points and customer_tokens are no longer used +// for balances (docs/prd-point-coin.md §9). + func (p *CustomerPointsProcessor) GetCustomerTotalPointsAPI(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) { - // Get total points - totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID) + id, err := parseWalletCustomerID(customerID) if err != nil { - return nil, fmt.Errorf("failed to get customer total points: %w", err) + return nil, err } - - // Get points history (last 10 records) - pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 10) + data, err := p.walletQuery.LegacyPoints(ctx, id) if err != nil { - return nil, fmt.Errorf("failed to get customer points history: %w", err) + return nil, fmt.Errorf("failed to get customer points: %w", err) } - - // Convert to response format - var historyItems []models.PointsHistoryItem - - for _, point := range pointsHistory { - historyItems = append(historyItems, models.PointsHistoryItem{ - ID: point.ID.String(), - Points: point.Balance, - Type: "BALANCE", - Description: "Points balance", - CreatedAt: point.CreatedAt, - }) - } - - var lastUpdated time.Time - if len(pointsHistory) > 0 { - lastUpdated = pointsHistory[0].CreatedAt - } - return &models.GetCustomerPointsResponse{ Status: "SUCCESS", Message: "Customer points retrieved successfully.", - Data: &models.GetCustomerPointsResponseData{ - TotalPoints: totalPoints, - PointsHistory: historyItems, - LastUpdated: lastUpdated, - }, + Data: data, }, nil } func (p *CustomerPointsProcessor) GetCustomerTotalTokensAPI(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) { - // Get total tokens - totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID) + id, err := parseWalletCustomerID(customerID) if err != nil { - return nil, fmt.Errorf("failed to get customer total tokens: %w", err) + return nil, err } - - // Get tokens history (last 10 records) - tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 10) + data, err := p.walletQuery.LegacyTokens(ctx, id) if err != nil { - return nil, fmt.Errorf("failed to get customer tokens history: %w", err) + return nil, fmt.Errorf("failed to get customer tokens: %w", err) } - - // Convert to response format - var historyItems []models.TokensHistoryItem - - for _, token := range tokensHistory { - historyItems = append(historyItems, models.TokensHistoryItem{ - ID: token.ID.String(), - Tokens: token.Balance, - Type: string(token.TokenType), - Description: "Tokens balance", - CreatedAt: token.CreatedAt, - }) - } - - var lastUpdated time.Time - if len(tokensHistory) > 0 { - lastUpdated = tokensHistory[0].CreatedAt - } - return &models.GetCustomerTokensResponse{ Status: "SUCCESS", Message: "Customer tokens retrieved successfully.", - Data: &models.GetCustomerTokensResponseData{ - TotalTokens: totalTokens, - TokensHistory: historyItems, - LastUpdated: lastUpdated, - }, + Data: data, }, nil } func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) { - // Get total points - totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID) + id, err := parseWalletCustomerID(customerID) if err != nil { - return nil, fmt.Errorf("failed to get customer total points: %w", err) + return nil, err } - - // Get total tokens - totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID) + data, err := p.walletQuery.Summary(ctx, id) if err != nil { - return nil, fmt.Errorf("failed to get customer total tokens: %w", err) + return nil, fmt.Errorf("failed to get customer wallet: %w", err) } - - // Get points history (last 5 records) - pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 5) - if err != nil { - return nil, fmt.Errorf("failed to get customer points history: %w", err) - } - - // Get tokens history (last 5 records) - tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 5) - if err != nil { - return nil, fmt.Errorf("failed to get customer tokens history: %w", err) - } - - // Convert to response format - var pointsHistoryItems []models.PointsHistoryItem - var tokensHistoryItems []models.TokensHistoryItem - var lastUpdated time.Time - - for _, point := range pointsHistory { - pointsHistoryItems = append(pointsHistoryItems, models.PointsHistoryItem{ - ID: point.ID.String(), - Points: point.Balance, - Type: "BALANCE", - Description: "Points balance", - CreatedAt: point.CreatedAt, - }) - if point.CreatedAt.After(lastUpdated) { - lastUpdated = point.CreatedAt - } - } - - for _, token := range tokensHistory { - tokensHistoryItems = append(tokensHistoryItems, models.TokensHistoryItem{ - ID: token.ID.String(), - Tokens: token.Balance, - Type: string(token.TokenType), - Description: "Tokens balance", - CreatedAt: token.CreatedAt, - }) - if token.CreatedAt.After(lastUpdated) { - lastUpdated = token.CreatedAt - } - } - return &models.GetCustomerWalletResponse{ Status: "SUCCESS", Message: "Customer wallet retrieved successfully.", - Data: &models.GetCustomerWalletResponseData{ - TotalPoints: totalPoints, - TotalTokens: totalTokens, - PointsHistory: pointsHistoryItems, - TokensHistory: tokensHistoryItems, - LastUpdated: lastUpdated, - }, + Data: data, }, nil } +func (p *CustomerPointsProcessor) GetCustomerWalletTransactionsAPI(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) { + id, err := parseWalletCustomerID(customerID) + if err != nil { + return nil, err + } + return p.walletQuery.Transactions(ctx, id, query) +} + +func parseWalletCustomerID(customerID string) (uuid.UUID, error) { + id, err := uuid.Parse(customerID) + if err != nil { + return uuid.Nil, fmt.Errorf("%w: invalid customer id", ErrInvalidWalletQuery) + } + return id, nil +} + // GetCustomerGamesAPI gets active SPIN games for customers func (p *CustomerPointsProcessor) GetCustomerGamesAPI(ctx context.Context) (*models.GetCustomerGamesResponse, error) { // Get active SPIN games diff --git a/internal/processor/wallet_query_processor.go b/internal/processor/wallet_query_processor.go new file mode 100644 index 0000000..4649eda --- /dev/null +++ b/internal/processor/wallet_query_processor.go @@ -0,0 +1,323 @@ +package processor + +import ( + "context" + "errors" + "fmt" + "strconv" + "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" +) + +// ErrInvalidWalletQuery wraps every rejection of a malformed history query, so the +// handler can answer with a validation error rather than a server error. +var ErrInvalidWalletQuery = errors.New("invalid wallet query") + +const ( + walletRecentTransactions = 5 + walletLegacyHistorySize = 10 + walletTransactionsPageLimit = 20 + walletTransactionsMaxLimit = 100 +) + +// walletDisplayLocation is where the customer's calendar days start and end, for +// date filters. Same zone as the database connection and the expiry grouping. +var walletDisplayLocation = func() *time.Location { + loc, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return time.FixedZone("WIB", 7*60*60) + } + return loc +}() + +// WalletQueryProcessor answers what the customer app shows about a wallet: balances, +// history and upcoming expiry (docs/prd-point-coin.md F6). It only reads. +type WalletQueryProcessor struct { + repo repository.WalletQueryRepository + settings repository.OrganizationSettingRepository + now func() time.Time +} + +func NewWalletQueryProcessor(repo repository.WalletQueryRepository, settings repository.OrganizationSettingRepository) *WalletQueryProcessor { + return &WalletQueryProcessor{repo: repo, settings: settings, now: time.Now} +} + +// Summary is GET /customer/wallet. A customer who has never had a wallet gets zeros. +func (p *WalletQueryProcessor) Summary(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerWalletResponseData, error) { + organizationID, err := p.repo.GetCustomerOrganizationID(ctx, customerID) + if err != nil { + return nil, err + } + now := p.now() + + balances, err := p.repo.SpendableBalances(ctx, customerID, now) + if err != nil { + return nil, err + } + pointValue, err := p.pointValue(ctx, organizationID) + if err != nil { + return nil, err + } + expiring, err := p.repo.NearestExpiring(ctx, customerID, now) + if err != nil { + return nil, err + } + recent, err := p.recent(ctx, customerID, "", walletRecentTransactions) + if err != nil { + return nil, err + } + points, err := p.legacyPoints(ctx, customerID, balances) + if err != nil { + return nil, err + } + tokens, err := p.legacyTokens(ctx, customerID, balances) + if err != nil { + return nil, err + } + + data := &models.GetCustomerWalletResponseData{ + PointBalance: balances[constants.WalletCurrencyPoint], + CoinBalance: balances[constants.WalletCurrencyCoin], + PointValue: pointValue, + PointDiscountValue: balances[constants.WalletCurrencyPoint] * pointValue, + RecentTransactions: recent, + TotalPoints: points.TotalPoints, + TotalTokens: tokens.TotalTokens, + PointsHistory: points.PointsHistory, + TokensHistory: tokens.TokensHistory, + LastUpdated: points.LastUpdated, + } + if tokens.LastUpdated.After(data.LastUpdated) { + data.LastUpdated = tokens.LastUpdated + } + for _, e := range expiring { + entry := &models.CustomerWalletExpiring{Amount: e.Amount, Date: e.Date} + switch e.Currency { + case constants.WalletCurrencyPoint: + data.NearestExpiring.Point = entry + case constants.WalletCurrencyCoin: + data.NearestExpiring.Coin = entry + } + } + return data, nil +} + +// Transactions is GET /customer/wallet/transactions. +func (p *WalletQueryProcessor) Transactions(ctx context.Context, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) { + filter, page, err := walletTransactionFilter(customerID, query) + if err != nil { + return nil, err + } + rows, total, err := p.repo.ListTransactions(ctx, filter) + if err != nil { + return nil, err + } + items, err := p.toTransactions(ctx, rows) + if err != nil { + return nil, err + } + totalPages := int((total + int64(filter.Limit) - 1) / int64(filter.Limit)) + return &models.PaginatedResponse[models.CustomerWalletTransaction]{ + Data: items, + Pagination: models.Pagination{Page: page, Limit: filter.Limit, Total: total, TotalPages: totalPages}, + }, nil +} + +// LegacyPoints is the pre-wallet GET /customer/points, now read from the wallet. +func (p *WalletQueryProcessor) LegacyPoints(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerPointsResponseData, error) { + balances, err := p.repo.SpendableBalances(ctx, customerID, p.now()) + if err != nil { + return nil, err + } + return p.legacyPoints(ctx, customerID, balances) +} + +func (p *WalletQueryProcessor) legacyPoints(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerPointsResponseData, error) { + recent, err := p.recent(ctx, customerID, constants.WalletCurrencyPoint, walletLegacyHistorySize) + if err != nil { + return nil, err + } + data := &models.GetCustomerPointsResponseData{TotalPoints: balances[constants.WalletCurrencyPoint]} + for _, tx := range recent { + data.PointsHistory = append(data.PointsHistory, models.PointsHistoryItem{ + ID: tx.ID.String(), Points: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt, + }) + } + if len(recent) > 0 { + data.LastUpdated = recent[0].CreatedAt + } + return data, nil +} + +// LegacyTokens is the pre-wallet GET /customer/tokens. Tokens are EnakCoin now. +func (p *WalletQueryProcessor) LegacyTokens(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerTokensResponseData, error) { + balances, err := p.repo.SpendableBalances(ctx, customerID, p.now()) + if err != nil { + return nil, err + } + return p.legacyTokens(ctx, customerID, balances) +} + +func (p *WalletQueryProcessor) legacyTokens(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerTokensResponseData, error) { + recent, err := p.recent(ctx, customerID, constants.WalletCurrencyCoin, walletLegacyHistorySize) + if err != nil { + return nil, err + } + data := &models.GetCustomerTokensResponseData{TotalTokens: balances[constants.WalletCurrencyCoin]} + for _, tx := range recent { + data.TokensHistory = append(data.TokensHistory, models.TokensHistoryItem{ + ID: tx.ID.String(), Tokens: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt, + }) + } + if len(recent) > 0 { + data.LastUpdated = recent[0].CreatedAt + } + return data, nil +} + +func (p *WalletQueryProcessor) recent(ctx context.Context, customerID uuid.UUID, currency string, limit int) ([]models.CustomerWalletTransaction, error) { + rows, _, err := p.repo.ListTransactions(ctx, repository.WalletTransactionFilter{ + CustomerID: customerID, Currency: currency, Limit: limit, + }) + if err != nil { + return nil, err + } + return p.toTransactions(ctx, rows) +} + +// pointValue reads the organization's rupiah value of one EnakPoint. A missing or +// unusable value falls back to the default rather than failing the whole screen. +func (p *WalletQueryProcessor) pointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) { + raw, ok, err := p.settings.Get(ctx, organizationID, constants.LoyaltyPointValueKey) + if err != nil { + return 0, err + } + if !ok { + return constants.LoyaltyPointValueDefault, nil + } + value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil || value < 1 { + return constants.LoyaltyPointValueDefault, nil + } + return value, nil +} + +func (p *WalletQueryProcessor) toTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) { + var credits []uuid.UUID + for _, row := range rows { + if row.Amount > 0 { + credits = append(credits, row.ID) + } + } + lots, err := p.repo.ListLotsBySourceTransactions(ctx, credits) + if err != nil { + return nil, err + } + lotsByTx := make(map[uuid.UUID][]entities.WalletLot, len(credits)) + for _, lot := range lots { + lotsByTx[lot.SourceTransactionID] = append(lotsByTx[lot.SourceTransactionID], lot) + } + + items := make([]models.CustomerWalletTransaction, 0, len(rows)) + for _, row := range rows { + ref := &models.CustomerWalletTransactionRef{Type: row.ReferenceType, ID: row.ReferenceID} + item := models.CustomerWalletTransaction{ + ID: row.ID, + Currency: row.Currency, + Type: row.Type, + Amount: row.Amount, + BalanceAfter: row.BalanceAfter, + Description: row.Description, + OutletID: row.OutletID, + ReversesTransactionID: row.ReversesTransactionID, + GroupID: row.GroupID, + CreatedAt: row.CreatedAt, + } + // The counterparty's id, the admin and the metadata are left out on purpose: + // the customer sees the masked name in the description (§8.1). + if row.Amount > 0 { + item.Source = ref + for _, lot := range lotsByTx[row.ID] { + item.Lots = append(item.Lots, models.CustomerWalletLot{ + Amount: lot.OriginalAmount, Remaining: lot.RemainingAmount, ExpiresAt: lot.ExpiresAt, + }) + if lot.ExpiresAt != nil && (item.ExpiresAt == nil || lot.ExpiresAt.Before(*item.ExpiresAt)) { + item.ExpiresAt = lot.ExpiresAt + } + } + } else { + item.Destination = ref + } + items = append(items, item) + } + return items, nil +} + +func walletTransactionFilter(customerID uuid.UUID, q models.ListCustomerWalletTransactionsQuery) (repository.WalletTransactionFilter, int, error) { + invalid := func(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrInvalidWalletQuery, fmt.Sprintf(format, args...)) + } + filter := repository.WalletTransactionFilter{CustomerID: customerID} + + page := q.Page + if page == 0 { + page = 1 + } + if page < 1 { + return filter, 0, invalid("page must be at least 1") + } + filter.Limit = q.Limit + if filter.Limit == 0 { + filter.Limit = walletTransactionsPageLimit + } + if filter.Limit < 1 || filter.Limit > walletTransactionsMaxLimit { + return filter, 0, invalid("limit must be between 1 and %d", walletTransactionsMaxLimit) + } + filter.Offset = (page - 1) * filter.Limit + + if q.Currency != "" { + currency := strings.ToUpper(q.Currency) + if !constants.IsValidWalletCurrency(currency) { + return filter, 0, invalid("currency must be POINT or COIN") + } + filter.Currency = currency + } + for _, t := range strings.Split(q.Type, ",") { + t = strings.ToUpper(strings.TrimSpace(t)) + if t == "" { + continue + } + if _, ok := walletTypeRules[t]; !ok { + return filter, 0, invalid("unknown type %q", t) + } + filter.Types = append(filter.Types, t) + } + + if q.From != "" { + from, err := time.ParseInLocation("2006-01-02", q.From, walletDisplayLocation) + if err != nil { + return filter, 0, invalid("from must be a date like 2026-01-31") + } + filter.From = &from + } + if q.To != "" { + to, err := time.ParseInLocation("2006-01-02", q.To, walletDisplayLocation) + if err != nil { + return filter, 0, invalid("to must be a date like 2026-01-31") + } + // To is inclusive for the customer, exclusive in the query. + end := to.AddDate(0, 0, 1) + filter.To = &end + } + if filter.From != nil && filter.To != nil && !filter.From.Before(*filter.To) { + return filter, 0, invalid("from must not be after to") + } + return filter, page, nil +} diff --git a/internal/processor/wallet_query_processor_test.go b/internal/processor/wallet_query_processor_test.go new file mode 100644 index 0000000..573f3df --- /dev/null +++ b/internal/processor/wallet_query_processor_test.go @@ -0,0 +1,246 @@ +package processor + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +type walletQueryRepoFake struct { + org uuid.UUID + spendable map[string]int64 + expiring []repository.WalletExpiringAmount + transactions []entities.WalletTransaction + total int64 + lots []entities.WalletLot + filters []repository.WalletTransactionFilter +} + +func (f *walletQueryRepoFake) GetCustomerOrganizationID(context.Context, uuid.UUID) (uuid.UUID, error) { + if f.org == uuid.Nil { + return uuid.Nil, repository.ErrWalletNotFound + } + return f.org, nil +} + +func (f *walletQueryRepoFake) SpendableBalances(context.Context, uuid.UUID, time.Time) (map[string]int64, error) { + return f.spendable, nil +} + +func (f *walletQueryRepoFake) NearestExpiring(context.Context, uuid.UUID, time.Time) ([]repository.WalletExpiringAmount, error) { + return f.expiring, nil +} + +func (f *walletQueryRepoFake) ListTransactions(_ context.Context, filter repository.WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) { + f.filters = append(f.filters, filter) + var out []entities.WalletTransaction + for _, tx := range f.transactions { + if filter.Currency == "" || tx.Currency == filter.Currency { + out = append(out, tx) + } + } + if len(out) > filter.Limit { + out = out[:filter.Limit] + } + return out, f.total, nil +} + +func (f *walletQueryRepoFake) ListLotsBySourceTransactions(_ context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) { + var out []entities.WalletLot + for _, lot := range f.lots { + for _, id := range ids { + if lot.SourceTransactionID == id { + out = append(out, lot) + } + } + } + return out, nil +} + +type orgSettingsFake map[string]string + +func (f orgSettingsFake) Get(_ context.Context, _ uuid.UUID, key string) (string, bool, error) { + v, ok := f[key] + return v, ok, nil +} + +func newWalletQueryTest(repo *walletQueryRepoFake, settings orgSettingsFake) *WalletQueryProcessor { + p := NewWalletQueryProcessor(repo, settings) + p.now = func() time.Time { return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) } + return p +} + +func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.T) { + order, payment := uuid.New(), uuid.New() + earnID, payID := uuid.New(), uuid.New() + soon := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + later := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + created := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + repo := &walletQueryRepoFake{ + org: uuid.New(), + spendable: map[string]int64{constants.WalletCurrencyPoint: 250, constants.WalletCurrencyCoin: 8}, + expiring: []repository.WalletExpiringAmount{ + {Currency: constants.WalletCurrencyPoint, Date: "2026-07-01", Amount: 100}, + }, + transactions: []entities.WalletTransaction{ + {ID: payID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, Amount: -50, BalanceAfter: 250, + ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, Description: "Bayar #ORD-1", CreatedAt: created.Add(time.Hour), + CounterpartyCustomerID: ptr(uuid.New()), Metadata: entities.Metadata{"point_value": 100}}, + {ID: earnID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn, Amount: 300, BalanceAfter: 300, + ReferenceType: constants.WalletRefTypeOrder, ReferenceID: order, Description: "Belanja #ORD-1", CreatedAt: created}, + }, + lots: []entities.WalletLot{ + {SourceTransactionID: earnID, OriginalAmount: 200, RemainingAmount: 150, ExpiresAt: &later}, + {SourceTransactionID: earnID, OriginalAmount: 100, RemainingAmount: 100, ExpiresAt: &soon}, + }, + } + p := newWalletQueryTest(repo, orgSettingsFake{constants.LoyaltyPointValueKey: "100"}) + + data, err := p.Summary(context.Background(), uuid.New()) + require.NoError(t, err) + + assert.Equal(t, int64(250), data.PointBalance) + assert.Equal(t, int64(8), data.CoinBalance) + assert.Equal(t, int64(100), data.PointValue) + assert.Equal(t, int64(25000), data.PointDiscountValue) + assert.Equal(t, &models.CustomerWalletExpiring{Amount: 100, Date: "2026-07-01"}, data.NearestExpiring.Point) + assert.Nil(t, data.NearestExpiring.Coin) + + require.Len(t, data.RecentTransactions, 2) + pay, earn := data.RecentTransactions[0], data.RecentTransactions[1] + assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypePayment, ID: payment}, pay.Destination) + assert.Nil(t, pay.Source) + assert.Empty(t, pay.Lots) + assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypeOrder, ID: order}, earn.Source) + assert.Nil(t, earn.Destination) + assert.Equal(t, &soon, earn.ExpiresAt, "earliest expiry among the lots the credit created") + assert.Len(t, earn.Lots, 2) + + // The pre-wallet fields are still filled, from the wallet. + assert.Equal(t, int64(250), data.TotalPoints) + assert.Equal(t, int64(8), data.TotalTokens) + require.Len(t, data.PointsHistory, 2) + assert.Equal(t, int64(-50), data.PointsHistory[0].Points) + assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type) + assert.Equal(t, created.Add(time.Hour), data.LastUpdated) +} + +func TestWalletQueryProcessor_PointValueFallsBackToDefault(t *testing.T) { + for name, settings := range map[string]orgSettingsFake{ + "not set": {}, + "garbage": {constants.LoyaltyPointValueKey: "abc"}, + "zero": {constants.LoyaltyPointValueKey: "0"}, + "negative": {constants.LoyaltyPointValueKey: "-5"}, + } { + t.Run(name, func(t *testing.T) { + repo := &walletQueryRepoFake{org: uuid.New(), spendable: map[string]int64{constants.WalletCurrencyPoint: 40}} + data, err := newWalletQueryTest(repo, settings).Summary(context.Background(), uuid.New()) + require.NoError(t, err) + assert.Equal(t, constants.LoyaltyPointValueDefault, data.PointValue) + assert.Equal(t, int64(40), data.PointDiscountValue) + }) + } +} + +func TestWalletQueryProcessor_SummaryForCustomerWithoutWallet(t *testing.T) { + repo := &walletQueryRepoFake{org: uuid.New(), spendable: map[string]int64{}} + data, err := newWalletQueryTest(repo, orgSettingsFake{}).Summary(context.Background(), uuid.New()) + require.NoError(t, err) + assert.Zero(t, data.PointBalance) + assert.Zero(t, data.CoinBalance) + assert.NotNil(t, data.RecentTransactions, "an empty list, not null, for the app") + + _, err = newWalletQueryTest(&walletQueryRepoFake{}, orgSettingsFake{}).Summary(context.Background(), uuid.New()) + assert.ErrorIs(t, err, repository.ErrWalletNotFound) +} + +func TestWalletQueryProcessor_LegacyEndpointsSplitByCurrency(t *testing.T) { + repo := &walletQueryRepoFake{ + org: uuid.New(), + spendable: map[string]int64{constants.WalletCurrencyPoint: 100, constants.WalletCurrencyCoin: 8}, + transactions: []entities.WalletTransaction{ + {ID: uuid.New(), Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration, Amount: 8}, + {ID: uuid.New(), Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: 100}, + }, + } + p := newWalletQueryTest(repo, orgSettingsFake{}) + + points, err := p.LegacyPoints(context.Background(), uuid.New()) + require.NoError(t, err) + assert.Equal(t, int64(100), points.TotalPoints) + require.Len(t, points.PointsHistory, 1) + assert.Equal(t, int64(100), points.PointsHistory[0].Points) + + tokens, err := p.LegacyTokens(context.Background(), uuid.New()) + require.NoError(t, err) + assert.Equal(t, int64(8), tokens.TotalTokens) + require.Len(t, tokens.TokensHistory, 1) + assert.Equal(t, int64(8), tokens.TokensHistory[0].Tokens) +} + +func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) { + customer := uuid.New() + jakarta := walletDisplayLocation + + repo := &walletQueryRepoFake{org: uuid.New(), total: 45} + page, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{ + Page: 3, Limit: 10, Currency: "point", Type: "earn, PAYMENT", From: "2026-05-01", To: "2026-05-31", + }) + require.NoError(t, err) + assert.Equal(t, models.Pagination{Page: 3, Limit: 10, Total: 45, TotalPages: 5}, page.Pagination) + assert.NotNil(t, page.Data) + + f := repo.filters[0] + assert.Equal(t, customer, f.CustomerID) + assert.Equal(t, 20, f.Offset) + assert.Equal(t, 10, f.Limit) + assert.Equal(t, constants.WalletCurrencyPoint, f.Currency) + assert.Equal(t, []string{constants.WalletTxTypeEarn, constants.WalletTxTypePayment}, f.Types) + assert.True(t, f.From.Equal(time.Date(2026, 5, 1, 0, 0, 0, 0, jakarta))) + assert.True(t, f.To.Equal(time.Date(2026, 6, 1, 0, 0, 0, 0, jakarta)), "to covers the whole last day") + + // Defaults. + repo = &walletQueryRepoFake{org: uuid.New()} + page, err = newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{}) + require.NoError(t, err) + assert.Equal(t, 1, page.Pagination.Page) + assert.Equal(t, walletTransactionsPageLimit, repo.filters[0].Limit) + assert.Zero(t, repo.filters[0].Offset) + assert.Nil(t, repo.filters[0].From) + assert.Empty(t, repo.filters[0].Types) +} + +func TestWalletQueryProcessor_RejectsBadQueries(t *testing.T) { + for name, q := range map[string]models.ListCustomerWalletTransactionsQuery{ + "negative page": {Page: -1}, + "limit too big": {Limit: walletTransactionsMaxLimit + 1}, + "negative limit": {Limit: -1}, + "unknown currency": {Currency: "GOLD"}, + "unknown type": {Type: "EARN,BONUS"}, + "bad from": {From: "01-05-2026"}, + "bad to": {To: "2026-13-01"}, + "from after to": {From: "2026-06-02", To: "2026-06-01"}, + } { + t.Run(name, func(t *testing.T) { + repo := &walletQueryRepoFake{org: uuid.New()} + _, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), uuid.New(), q) + assert.ErrorIs(t, err, ErrInvalidWalletQuery) + assert.Empty(t, repo.filters, "nothing is queried") + }) + } + + // A single day is a valid range. + repo := &walletQueryRepoFake{org: uuid.New()} + _, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), uuid.New(), + models.ListCustomerWalletTransactionsQuery{From: "2026-06-01", To: "2026-06-01"}) + assert.NoError(t, err) +} diff --git a/internal/repository/organization_setting_repository.go b/internal/repository/organization_setting_repository.go new file mode 100644 index 0000000..7c47697 --- /dev/null +++ b/internal/repository/organization_setting_repository.go @@ -0,0 +1,41 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// OrganizationSettingRepository reads organization_settings, the key-value settings +// that apply to every outlet of an organization (docs/prd-point-coin.md F2). +type OrganizationSettingRepository interface { + // Get returns the stored value of a key and whether one is stored. A key that has + // never been set is not an error: the caller falls back to the key's default. + Get(ctx context.Context, organizationID uuid.UUID, key string) (string, bool, error) +} + +type organizationSettingRepository struct { + db *gorm.DB +} + +func NewOrganizationSettingRepository(db *gorm.DB) OrganizationSettingRepository { + return &organizationSettingRepository{db: db} +} + +func (r *organizationSettingRepository) Get(ctx context.Context, organizationID uuid.UUID, key string) (string, bool, error) { + var values []*string + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table("organization_settings"). + Where("organization_id = ? AND key = ?", organizationID, key). + Limit(1). + Pluck("value", &values).Error + if err != nil { + return "", false, fmt.Errorf("failed to get organization setting %s: %w", key, err) + } + if len(values) == 0 || values[0] == nil { + return "", false, nil + } + return *values[0], true, nil +} diff --git a/internal/repository/wallet_query_repository.go b/internal/repository/wallet_query_repository.go new file mode 100644 index 0000000..f59b75c --- /dev/null +++ b/internal/repository/wallet_query_repository.go @@ -0,0 +1,170 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + "apskel-pos-be/internal/entities" +) + +// walletDisplayTimeZone is the zone expiry dates are grouped in for display, the same +// one the database connection uses (config.Database.DSN). +const walletDisplayTimeZone = "Asia/Jakarta" + +// WalletTransactionFilter narrows a customer's ledger. Zero values mean no filter. +type WalletTransactionFilter struct { + CustomerID uuid.UUID + Currency string + Types []string + // From is inclusive and To is exclusive. + From *time.Time + To *time.Time + Offset int + Limit int +} + +// WalletExpiringAmount is how much of one currency expires on one day. +type WalletExpiringAmount struct { + Currency string + // A calendar date in walletDisplayTimeZone, formatted YYYY-MM-DD. + Date string + Amount int64 +} + +// WalletQueryRepository serves the read side of the wallet: balances, history and +// upcoming expiry for display. Nothing here changes a balance. +type WalletQueryRepository interface { + // GetCustomerOrganizationID returns ErrWalletNotFound when the customer does not exist. + GetCustomerOrganizationID(ctx context.Context, customerID uuid.UUID) (uuid.UUID, error) + // SpendableBalances sums, per currency, the lots that still have balance and have + // not expired at asOf. It differs from the wallet balance only for lots that have + // expired but that the expiry job has not processed yet. + SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) + // NearestExpiring returns, per currency, the earliest day after asOf on which some + // balance expires, and how much expires that day. + NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) + // ListTransactions returns a page of the ledger, newest first, and the total count. + ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) + // ListLotsBySourceTransactions returns the lots created by the given credits. + ListLotsBySourceTransactions(ctx context.Context, transactionIDs []uuid.UUID) ([]entities.WalletLot, error) +} + +type walletQueryRepository struct { + db *gorm.DB +} + +func NewWalletQueryRepository(db *gorm.DB) WalletQueryRepository { + return &walletQueryRepository{db: db} +} + +func (r *walletQueryRepository) GetCustomerOrganizationID(ctx context.Context, customerID uuid.UUID) (uuid.UUID, error) { + var ids []string + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table("customers"). + Where("id = ?", customerID). + Limit(1). + Pluck("organization_id::text", &ids).Error + if err != nil { + return uuid.Nil, fmt.Errorf("failed to get customer organization: %w", err) + } + if len(ids) == 0 { + return uuid.Nil, ErrWalletNotFound + } + return uuid.Parse(ids[0]) +} + +func (r *walletQueryRepository) SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) { + var rows []struct { + Currency string + Amount int64 + } + err := DBFromContext(ctx, r.db).WithContext(ctx). + Model(&entities.WalletLot{}). + Select("currency, SUM(remaining_amount) AS amount"). + Where("customer_id = ? AND remaining_amount > 0", customerID). + Where("(expires_at IS NULL OR expires_at > ?)", asOf). + Group("currency"). + Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to sum spendable wallet balance: %w", err) + } + balances := make(map[string]int64, len(rows)) + for _, row := range rows { + balances[row.Currency] = row.Amount + } + return balances, nil +} + +func (r *walletQueryRepository) NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) { + var rows []WalletExpiringAmount + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + WITH by_day AS ( + SELECT currency, + (expires_at AT TIME ZONE ?)::date AS day, + SUM(remaining_amount) AS amount + FROM wallet_lots + WHERE customer_id = ? AND remaining_amount > 0 AND expires_at > ? + GROUP BY currency, day + ) + SELECT DISTINCT ON (currency) currency, to_char(day, 'YYYY-MM-DD') AS date, amount + FROM by_day + ORDER BY currency, day`, walletDisplayTimeZone, customerID, asOf). + Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to get nearest expiring wallet balance: %w", err) + } + return rows, nil +} + +func (r *walletQueryRepository) ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) { + query := DBFromContext(ctx, r.db).WithContext(ctx). + Model(&entities.WalletTransaction{}). + Where("customer_id = ?", filter.CustomerID) + if filter.Currency != "" { + query = query.Where("currency = ?", filter.Currency) + } + if len(filter.Types) > 0 { + query = query.Where("type IN ?", filter.Types) + } + if filter.From != nil { + query = query.Where("created_at >= ?", *filter.From) + } + if filter.To != nil { + query = query.Where("created_at < ?", *filter.To) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count wallet transactions: %w", err) + } + + var transactions []entities.WalletTransaction + err := query. + Order("created_at DESC, id DESC"). + Offset(filter.Offset). + Limit(filter.Limit). + Find(&transactions).Error + if err != nil { + return nil, 0, fmt.Errorf("failed to list wallet transactions: %w", err) + } + return transactions, total, nil +} + +func (r *walletQueryRepository) ListLotsBySourceTransactions(ctx context.Context, transactionIDs []uuid.UUID) ([]entities.WalletLot, error) { + var lots []entities.WalletLot + if len(transactionIDs) == 0 { + return lots, nil + } + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("source_transaction_id IN ?", transactionIDs). + Order("expires_at NULLS LAST, created_at, id"). + Find(&lots).Error + if err != nil { + return nil, fmt.Errorf("failed to list wallet lots: %w", err) + } + return lots, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index 6da7d3f..0a5dda6 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -157,6 +157,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.GET("/points", r.customerPointsHandler.GetCustomerPoints) customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens) customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) + customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions) customer.GET("/games", r.customerPointsHandler.GetCustomerGames) customer.GET("/ferris-wheel", r.customerPointsHandler.GetFerrisWheelGame) customer.POST("/spin", r.spinGameHandler.PlaySpinGame) diff --git a/internal/service/customer_points_service.go b/internal/service/customer_points_service.go index 20b473d..a247845 100644 --- a/internal/service/customer_points_service.go +++ b/internal/service/customer_points_service.go @@ -12,6 +12,7 @@ type CustomerPointsService interface { GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) + GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) GetCustomerGames(ctx context.Context) (*models.GetCustomerGamesResponse, error) GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) } @@ -82,3 +83,10 @@ func (s *customerPointsService) GetFerrisWheelGame(ctx context.Context) (*models return response, nil } + +func (s *customerPointsService) GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) { + if customerID == "" { + return nil, fmt.Errorf("customer ID is required") + } + return s.customerPointsProcessor.GetCustomerWalletTransactionsAPI(ctx, customerID, query) +} -- 2.54.0 From a6d5a8b056a74e6056cbf45538a20e8026a62820 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 09:36:18 +0700 Subject: [PATCH 18/47] feat(wallet): customer wallet and manual adjustments in the dashboard Adds the dashboard side of a customer's wallet (docs/prd-point-coin.md F7, PC-107), under /marketing for admins and managers: - GET /marketing/customers/:id/wallet returns the customer, the ledger and spendable balances, every lot that still holds something (flagged when expired), and a page of history. Unlike the customer's own view, each row carries the real names behind it: the transfer counterparty, the admin or cashier, and the outlet, plus the reason and metadata. - POST /marketing/customers/:id/wallet/adjust takes a signed amount and a required reason. It writes an ADJUSTMENT pointing at the admin through the wallet engine, refuses to take more than the customer can spend, and accepts an optional idempotency key so a retried request adjusts once. Reasons describing a cash-out are refused (K7). The customer must belong to the caller's organization; otherwise both endpoints answer 404. Positive adjustments create non-expiring lots until the expiry model is decided (F12, note N4). The mapping from ledger rows to what the apps show is now shared between the customer and dashboard views. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 8 + internal/constants/error.go | 1 + internal/contract/wallet_contract.go | 14 ++ internal/handler/wallet_admin_db_test.go | 203 ++++++++++++++++ internal/handler/wallet_admin_handler.go | 85 +++++++ internal/models/wallet.go | 69 ++++++ internal/processor/wallet_admin_processor.go | 225 ++++++++++++++++++ internal/processor/wallet_query_processor.go | 8 +- .../repository/wallet_admin_repository.go | 126 ++++++++++ internal/router/router.go | 11 +- internal/service/wallet_admin_service.go | 69 ++++++ internal/validator/wallet_validator.go | 66 +++++ internal/validator/wallet_validator_test.go | 43 ++++ 13 files changed, 926 insertions(+), 2 deletions(-) create mode 100644 internal/contract/wallet_contract.go create mode 100644 internal/handler/wallet_admin_db_test.go create mode 100644 internal/handler/wallet_admin_handler.go create mode 100644 internal/processor/wallet_admin_processor.go create mode 100644 internal/repository/wallet_admin_repository.go create mode 100644 internal/service/wallet_admin_service.go create mode 100644 internal/validator/wallet_validator.go create mode 100644 internal/validator/wallet_validator_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 1183350..3e7f1be 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -142,6 +142,8 @@ func (a *App) Initialize(cfg *config.Config) error { validators.expenseValidator, services.cashAdvanceService, validators.cashAdvanceValidator, + services.walletAdminService, + validators.walletValidator, a.redisClient, ) @@ -357,6 +359,7 @@ type processors struct { expenseProcessor *processor.ExpenseProcessorImpl cashAdvanceProcessor *processor.CashAdvanceProcessorImpl walletProcessor *processor.WalletProcessor + walletAdminProcessor *processor.WalletAdminProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -411,6 +414,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo), cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo), walletProcessor: processor.NewWalletProcessor(repos.walletRepo), + walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), } } @@ -453,6 +457,7 @@ type services struct { productOutletPriceService service.ProductOutletPriceService expenseService *service.ExpenseServiceImpl cashAdvanceService *service.CashAdvanceServiceImpl + walletAdminService *service.WalletAdminServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -534,6 +539,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor), expenseService: service.NewExpenseService(processors.expenseProcessor), cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor), + walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor), } } @@ -579,6 +585,7 @@ type validators struct { productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl expenseValidator *validator.ExpenseValidatorImpl cashAdvanceValidator *validator.CashAdvanceValidatorImpl + walletValidator *validator.WalletValidatorImpl } func (a *App) initValidators() *validators { @@ -612,6 +619,7 @@ func (a *App) initValidators() *validators { productOutletPriceValidator: validator.NewProductOutletPriceValidator(), expenseValidator: validator.NewExpenseValidator(), cashAdvanceValidator: validator.NewCashAdvanceValidator(), + walletValidator: validator.NewWalletValidator(), } } diff --git a/internal/constants/error.go b/internal/constants/error.go index c2cd49b..6f09efd 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -63,6 +63,7 @@ const ( ProductOutletPriceServiceEntity = "product_outlet_price_service" ExpenseServiceEntity = "expense_service" CashAdvanceServiceEntity = "cash_advance_service" + WalletServiceEntity = "wallet_service" ) var HttpErrorMap = map[string]int{ diff --git a/internal/contract/wallet_contract.go b/internal/contract/wallet_contract.go new file mode 100644 index 0000000..3e0e58a --- /dev/null +++ b/internal/contract/wallet_contract.go @@ -0,0 +1,14 @@ +package contract + +// AdjustCustomerWalletRequest is POST /marketing/customers/:id/wallet/adjust +// (docs/prd-point-coin.md F7). +type AdjustCustomerWalletRequest struct { + // POINT or COIN. + Currency string `json:"currency"` + // Signed: positive adds to the balance, negative takes from it. Never zero. + Amount int64 `json:"amount"` + Reason string `json:"reason"` + // Optional. Sending the same key again returns the first result instead of + // adjusting twice, so a retried request is safe. + IdempotencyKey string `json:"idempotency_key"` +} diff --git a/internal/handler/wallet_admin_db_test.go b/internal/handler/wallet_admin_db_test.go new file mode 100644 index 0000000..85d0adc --- /dev/null +++ b/internal/handler/wallet_admin_db_test.go @@ -0,0 +1,203 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + "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/appcontext" + "apskel-pos-be/internal/constants" + applogger "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/validator" +) + +// Drives the dashboard wallet endpoints over HTTP down to Postgres. Needs +// TEST_DATABASE_URL pointing at a migrated database; see +// internal/repository/wallet_repository_test.go. +func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + applogger.Setup("fatal", "json") + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + + orgA, orgB := uuid.New(), uuid.New() + adminA, adminB := uuid.New(), uuid.New() + customer := uuid.New() + 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 (?, 'A', 'basic'), (?, 'B', 'basic')`, orgA, orgB) + exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES + (?, ?, 'Admin Satu', ?, 'x', 'admin'), (?, ?, 'Admin Lain', ?, 'x', 'admin')`, + adminA, orgA, adminA.String()+"@test", adminB, orgB, adminB.String()+"@test") + exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Budi Santoso', '081234567890')`, customer, orgA) + t.Cleanup(func() { + db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id = ?)`, customer) + db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer) + db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer) + db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer) + db.Exec(`DELETE FROM customers WHERE id = ?`, customer) + db.Exec(`DELETE FROM users WHERE id IN ?`, []uuid.UUID{adminA, adminB}) + db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{orgA, orgB}) + }) + + walletRepo := repository.NewWalletRepository(db) + queryRepo := repository.NewWalletQueryRepository(db) + txm := repository.NewTxManager(db) + wallet := processor.NewWalletProcessor(walletRepo) + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + outlet := uuid.New() + _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ + CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn, + Amount: 100, ReferenceType: constants.WalletRefTypeOrder, ReferenceID: uuid.New(), OutletID: &outlet, + Description: "Belanja #ORD-1"}}) + return err + })) + + h := NewWalletAdminHandler( + service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm)), + validator.NewWalletValidator(), + ) + gin.SetMode(gin.TestMode) + router := gin.New() + as := func(org, user uuid.UUID) gin.HandlerFunc { + return func(c *gin.Context) { + ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String()) + ctx = context.WithValue(ctx, appcontext.UserIDKey, user.String()) + c.Request = c.Request.WithContext(ctx) + } + } + for prefix, who := range map[string][2]uuid.UUID{"/a": {orgA, adminA}, "/b": {orgB, adminB}} { + g := router.Group(prefix, as(who[0], who[1])) + g.GET("/customers/:id/wallet", h.GetCustomerWallet) + g.POST("/customers/:id/wallet/adjust", h.AdjustCustomerWallet) + } + call := func(method, path string, body any) (int, map[string]any) { + t.Helper() + var buf bytes.Buffer + if body != nil { + require.NoError(t, json.NewEncoder(&buf).Encode(body)) + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + var out map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String()) + return rec.Code, out + } + adjust := func(prefix string, body map[string]any) (int, map[string]any) { + return call(http.MethodPost, prefix+"/customers/"+customer.String()+"/wallet/adjust", body) + } + spendable := func() float64 { + t.Helper() + status, body := call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet", nil) + require.Equal(t, http.StatusOK, status, body) + return body["data"].(map[string]any)["spendable_point_balance"].(float64) + } + + // Add 50. + status, body := adjust("/a", map[string]any{"currency": "point", "amount": 50, "reason": "komplain #45"}) + require.Equal(t, http.StatusOK, status, body) + tx := body["data"].(map[string]any)["transaction"].(map[string]any) + assert.Equal(t, "ADJUSTMENT", tx["type"]) + assert.EqualValues(t, 50, tx["amount"]) + assert.Equal(t, "komplain #45", tx["reason"]) + assert.Equal(t, "Koreksi oleh admin: komplain #45", tx["description"]) + assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, tx["created_by"]) + assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, tx["source"]) + assert.EqualValues(t, 150, body["data"].(map[string]any)["spendable_point_balance"]) + + // Taking more than the customer can spend is refused and changes nothing. + status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -151, "reason": "salah input"}) + assert.Equal(t, http.StatusBadRequest, status, body) + assert.Equal(t, float64(150), spendable()) + + // Taking away, retried with the same key, happens once. + for i, wantReplayed := range []bool{false, true} { + status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -30, "reason": "salah input", "idempotency_key": "req-1"}) + require.Equal(t, http.StatusOK, status, body) + assert.Equal(t, wantReplayed, body["data"].(map[string]any)["replayed"], "call %d", i+1) + } + assert.Equal(t, float64(120), spendable()) + + // Bad requests. + for name, req := range map[string]map[string]any{ + "cash-out": {"currency": "POINT", "amount": -10, "reason": "pencairan saldo"}, + "zero amount": {"currency": "POINT", "amount": 0, "reason": "x"}, + "no reason": {"currency": "POINT", "amount": 10}, + "bad currency": {"currency": "GOLD", "amount": 10, "reason": "x"}, + } { + status, _ = adjust("/a", req) + assert.Equal(t, http.StatusBadRequest, status, name) + } + status, _ = call(http.MethodPost, "/a/customers/not-a-uuid/wallet/adjust", map[string]any{"currency": "POINT", "amount": 1, "reason": "x"}) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, float64(120), spendable()) + + // Another organization's admin cannot see or touch this customer. + status, _ = call(http.MethodGet, "/b/customers/"+customer.String()+"/wallet", nil) + assert.Equal(t, http.StatusNotFound, status) + status, _ = adjust("/b", map[string]any{"currency": "POINT", "amount": 1000, "reason": "x"}) + assert.Equal(t, http.StatusNotFound, status) + assert.Equal(t, float64(120), spendable()) + + // The dashboard view. + status, body = call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet?type=ADJUSTMENT", nil) + require.Equal(t, http.StatusOK, status, body) + w := body["data"].(map[string]any) + assert.Equal(t, map[string]any{"id": customer.String(), "name": "Budi Santoso", "phone": "081234567890"}, w["customer"]) + assert.EqualValues(t, 120, w["point_balance"]) + assert.EqualValues(t, 120, w["spendable_point_balance"]) + lots := w["lots"].([]any) + var lotTotal float64 + for _, l := range lots { + lotTotal += l.(map[string]any)["remaining_amount"].(float64) + } + assert.Equal(t, float64(120), lotTotal) + + rows := w["transactions"].(map[string]any)["data"].([]any) + require.Len(t, rows, 2, "the two adjustments, newest first") + newest := rows[0].(map[string]any) + assert.EqualValues(t, -30, newest["amount"]) + assert.Equal(t, "salah input", newest["reason"]) + assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, newest["created_by"]) + assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, newest["destination"]) + + // The customer's own history shows the adjustment too, with the reason in the + // description and without the admin's identity fields. + customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor( + processor.NewWalletQueryProcessor(queryRepo, repository.NewOrganizationSettingRepository(db)), nil))) + crouter := gin.New() + crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions) + rec := httptest.NewRecorder() + crouter.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/wallet/transactions?type=ADJUSTMENT", nil)) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + var cbody map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cbody)) + crows := cbody["data"].(map[string]any)["data"].([]any) + require.Len(t, crows, 2) + first := crows[0].(map[string]any) + assert.Equal(t, "Koreksi oleh admin: salah input", first["description"]) + assert.NotContains(t, first, "created_by") + assert.NotContains(t, first, "reason") +} diff --git a/internal/handler/wallet_admin_handler.go b/internal/handler/wallet_admin_handler.go new file mode 100644 index 0000000..ff2311e --- /dev/null +++ b/internal/handler/wallet_admin_handler.go @@ -0,0 +1,85 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" + "apskel-pos-be/internal/validator" +) + +// WalletAdminHandler serves /marketing/customers/:id/wallet (docs/prd-point-coin.md F7). +type WalletAdminHandler struct { + walletAdminService service.WalletAdminService + walletValidator validator.WalletValidator +} + +func NewWalletAdminHandler(walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator) *WalletAdminHandler { + return &WalletAdminHandler{walletAdminService: walletAdminService, walletValidator: walletValidator} +} + +func (h *WalletAdminHandler) GetCustomerWallet(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + customerID, err := uuid.Parse(c.Param("id")) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid customer ID"), + }), "WalletAdminHandler::GetCustomerWallet") + return + } + + var query models.ListCustomerWalletTransactionsQuery + if err := c.ShouldBindQuery(&query); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()), + }), "WalletAdminHandler::GetCustomerWallet") + return + } + + response := h.walletAdminService.GetCustomerWallet(ctx, contextInfo, customerID, query) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::GetCustomerWallet -> service call failed") + } + util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::GetCustomerWallet") +} + +func (h *WalletAdminHandler) AdjustCustomerWallet(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + customerID, err := uuid.Parse(c.Param("id")) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid customer ID"), + }), "WalletAdminHandler::AdjustCustomerWallet") + return + } + + var req contract.AdjustCustomerWalletRequest + if err := c.ShouldBindJSON(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()), + }), "WalletAdminHandler::AdjustCustomerWallet") + return + } + if validationError, code := h.walletValidator.ValidateAdjustRequest(&req); validationError != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(code, constants.RequestEntity, validationError.Error()), + }), "WalletAdminHandler::AdjustCustomerWallet") + return + } + + response := h.walletAdminService.AdjustCustomerWallet(ctx, contextInfo, customerID, &req) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::AdjustCustomerWallet -> service call failed") + } + util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::AdjustCustomerWallet") +} diff --git a/internal/models/wallet.go b/internal/models/wallet.go index 27f20bc..e9fe804 100644 --- a/internal/models/wallet.go +++ b/internal/models/wallet.go @@ -69,3 +69,72 @@ type ListCustomerWalletTransactionsQuery struct { From string `form:"from"` To string `form:"to"` } + +// AdminCustomerWallet is GET /marketing/customers/:id/wallet (docs/prd-point-coin.md +// F7). Unlike the customer's own view it shows the raw balances next to the spendable +// ones, every lot that still holds something, and the real names behind each row. +type AdminCustomerWallet struct { + Customer AdminWalletCustomer `json:"customer"` + // Balances as the ledger has them. + PointBalance int64 `json:"point_balance"` + CoinBalance int64 `json:"coin_balance"` + // What can be spent now. Lower than the ledger balance only while lots that have + // expired wait for the expiry job. + SpendablePointBalance int64 `json:"spendable_point_balance"` + SpendableCoinBalance int64 `json:"spendable_coin_balance"` + Lots []AdminWalletLot `json:"lots"` + Transactions PaginatedResponse[AdminWalletTransaction] `json:"transactions"` +} + +type AdminWalletCustomer struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Phone *string `json:"phone,omitempty"` +} + +type AdminWalletLot struct { + ID uuid.UUID `json:"id"` + Currency string `json:"currency"` + OriginalAmount int64 `json:"original_amount"` + RemainingAmount int64 `json:"remaining_amount"` + ExpiresAt *time.Time `json:"expires_at"` + Expired bool `json:"expired"` + SourceTransactionID uuid.UUID `json:"source_transaction_id"` + OriginLotID *uuid.UUID `json:"origin_lot_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// AdminWalletTransaction is a ledger row with the names the customer does not see: +// the real counterparty of a transfer, the admin behind an adjustment, the cashier who +// took a payment, and the outlet. +type AdminWalletTransaction struct { + CustomerWalletTransaction + Counterparty *AdminWalletNamedRef `json:"counterparty,omitempty"` + CreatedBy *AdminWalletNamedRef `json:"created_by,omitempty"` + Outlet *AdminWalletNamedRef `json:"outlet,omitempty"` + Reason *string `json:"reason,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type AdminWalletNamedRef struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` +} + +// WalletAdjustment is a manual correction by an admin. +type WalletAdjustment struct { + Currency string + // Signed: positive adds, negative takes away. + Amount int64 + Reason string + IdempotencyKey string +} + +// AdminWalletAdjustmentResult is what POST /marketing/customers/:id/wallet/adjust returns. +type AdminWalletAdjustmentResult struct { + Transaction AdminWalletTransaction `json:"transaction"` + SpendablePointBalance int64 `json:"spendable_point_balance"` + SpendableCoinBalance int64 `json:"spendable_coin_balance"` + // True when the idempotency key had been used before and nothing changed. + Replayed bool `json:"replayed"` +} diff --git a/internal/processor/wallet_admin_processor.go b/internal/processor/wallet_admin_processor.go new file mode 100644 index 0000000..86483f7 --- /dev/null +++ b/internal/processor/wallet_admin_processor.go @@ -0,0 +1,225 @@ +package processor + +import ( + "context" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// walletDescriptionLimit is the size of wallet_transactions.description. +const walletDescriptionLimit = 255 + +// WalletAdminProcessor serves the dashboard's view of one customer's wallet and the +// manual adjustments an admin makes to it (docs/prd-point-coin.md F7). +type WalletAdminProcessor struct { + admin repository.WalletAdminRepository + query repository.WalletQueryRepository + wallet *WalletProcessor + tx TxRunner + now func() time.Time +} + +func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, tx TxRunner) *WalletAdminProcessor { + return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, tx: tx, now: time.Now} +} + +// GetCustomerWallet returns the customer's balances, lots and a page of history. +// A customer outside the organization is reported as not found. +func (p *WalletAdminProcessor) GetCustomerWallet(ctx context.Context, organizationID, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) (*models.AdminCustomerWallet, error) { + filter, page, err := walletTransactionFilter(customerID, query) + if err != nil { + return nil, err + } + customer, err := p.admin.GetCustomer(ctx, organizationID, customerID) + if err != nil { + return nil, err + } + now := p.now() + + result := &models.AdminCustomerWallet{ + Customer: models.AdminWalletCustomer{ID: customer.ID, Name: customer.Name, Phone: customer.Phone}, + Lots: []models.AdminWalletLot{}, + } + wallet, err := p.admin.GetWallet(ctx, customerID) + if err != nil { + return nil, err + } + if wallet != nil { + result.PointBalance = wallet.PointBalance + result.CoinBalance = wallet.CoinBalance + } + spendable, err := p.query.SpendableBalances(ctx, customerID, now) + if err != nil { + return nil, err + } + result.SpendablePointBalance = spendable[constants.WalletCurrencyPoint] + result.SpendableCoinBalance = spendable[constants.WalletCurrencyCoin] + + lots, err := p.admin.ListLotsWithBalance(ctx, customerID) + if err != nil { + return nil, err + } + for _, lot := range lots { + result.Lots = append(result.Lots, models.AdminWalletLot{ + ID: lot.ID, + Currency: lot.Currency, + OriginalAmount: lot.OriginalAmount, + RemainingAmount: lot.RemainingAmount, + ExpiresAt: lot.ExpiresAt, + Expired: lot.ExpiresAt != nil && !lot.ExpiresAt.After(now), + SourceTransactionID: lot.SourceTransactionID, + OriginLotID: lot.OriginLotID, + CreatedAt: lot.CreatedAt, + }) + } + + rows, total, err := p.query.ListTransactions(ctx, filter) + if err != nil { + return nil, err + } + transactions, err := p.adminTransactions(ctx, rows) + if err != nil { + return nil, err + } + result.Transactions = models.PaginatedResponse[models.AdminWalletTransaction]{ + Data: transactions, + Pagination: models.Pagination{ + Page: page, + Limit: filter.Limit, + Total: total, + TotalPages: int((total + int64(filter.Limit) - 1) / int64(filter.Limit)), + }, + } + return result, nil +} + +// Adjust adds to or takes from a customer's balance by hand. It is recorded as an +// ADJUSTMENT pointing at the admin, with the reason. Taking more than the customer +// can spend is refused with repository.ErrWalletInsufficientBalance. +func (p *WalletAdminProcessor) Adjust(ctx context.Context, organizationID, adminID, customerID uuid.UUID, in models.WalletAdjustment) (*models.AdminWalletAdjustmentResult, error) { + reason := strings.TrimSpace(in.Reason) + if adminID == uuid.Nil { + return nil, fmt.Errorf("%w: the admin making the adjustment is unknown", ErrWalletInvalidEntry) + } + if in.Amount == 0 { + return nil, fmt.Errorf("%w: amount must not be zero", ErrWalletInvalidEntry) + } + + entry := WalletEntry{ + CustomerID: customerID, + Currency: in.Currency, + Type: constants.WalletTxTypeAdjustment, + Amount: abs(in.Amount), + ReferenceType: constants.WalletRefTypeUser, + ReferenceID: adminID, + CreatedByUser: &adminID, + Reason: &reason, + Description: truncateRunes("Koreksi oleh admin: "+reason, walletDescriptionLimit), + } + if in.IdempotencyKey != "" { + // Scoped to the customer so a key chosen by the dashboard cannot collide with + // the keys other flows use. + entry.IdempotencyKey = fmt.Sprintf("adjust:%s:%s", customerID, in.IdempotencyKey) + } + + var res *WalletResult + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + if _, err := p.admin.GetCustomer(ctx, organizationID, customerID); err != nil { + return err + } + var err error + if in.Amount > 0 { + // Adjustments never expire until expiry is decided (F12, note N4). + res, err = p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: entry}) + } else { + res, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: entry}) + } + return err + }) + if err != nil { + return nil, err + } + + transactions, err := p.adminTransactions(ctx, []entities.WalletTransaction{*res.Transaction}) + if err != nil { + return nil, err + } + spendable, err := p.query.SpendableBalances(ctx, customerID, p.now()) + if err != nil { + return nil, err + } + return &models.AdminWalletAdjustmentResult{ + Transaction: transactions[0], + SpendablePointBalance: spendable[constants.WalletCurrencyPoint], + SpendableCoinBalance: spendable[constants.WalletCurrencyCoin], + Replayed: res.Replayed, + }, nil +} + +// adminTransactions adds, to what the customer sees, the names behind each row. +func (p *WalletAdminProcessor) adminTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.AdminWalletTransaction, error) { + base, err := buildWalletTransactions(ctx, p.query, rows) + if err != nil { + return nil, err + } + + var customerIDs, userIDs, outletIDs []uuid.UUID + for _, row := range rows { + if row.CounterpartyCustomerID != nil { + customerIDs = append(customerIDs, *row.CounterpartyCustomerID) + } + if row.CreatedByUser != nil { + userIDs = append(userIDs, *row.CreatedByUser) + } + if row.OutletID != nil { + outletIDs = append(outletIDs, *row.OutletID) + } + } + customers, err := p.admin.CustomerNames(ctx, customerIDs) + if err != nil { + return nil, err + } + users, err := p.admin.UserNames(ctx, userIDs) + if err != nil { + return nil, err + } + outlets, err := p.admin.OutletNames(ctx, outletIDs) + if err != nil { + return nil, err + } + named := func(id *uuid.UUID, names map[uuid.UUID]string) *models.AdminWalletNamedRef { + if id == nil { + return nil + } + return &models.AdminWalletNamedRef{ID: *id, Name: names[*id]} + } + + out := make([]models.AdminWalletTransaction, 0, len(rows)) + for i, row := range rows { + out = append(out, models.AdminWalletTransaction{ + CustomerWalletTransaction: base[i], + Counterparty: named(row.CounterpartyCustomerID, customers), + CreatedBy: named(row.CreatedByUser, users), + Outlet: named(row.OutletID, outlets), + Reason: row.Reason, + Metadata: row.Metadata, + }) + } + return out, nil +} + +func truncateRunes(s string, limit int) string { + if utf8.RuneCountInString(s) <= limit { + return s + } + return string([]rune(s)[:limit]) +} diff --git a/internal/processor/wallet_query_processor.go b/internal/processor/wallet_query_processor.go index 4649eda..e1051dc 100644 --- a/internal/processor/wallet_query_processor.go +++ b/internal/processor/wallet_query_processor.go @@ -210,13 +210,19 @@ func (p *WalletQueryProcessor) pointValue(ctx context.Context, organizationID uu } func (p *WalletQueryProcessor) toTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) { + return buildWalletTransactions(ctx, p.repo, rows) +} + +// buildWalletTransactions turns ledger rows into what the apps show, in the same +// order: where each row came from or went to, and the lots each credit created. +func buildWalletTransactions(ctx context.Context, repo repository.WalletQueryRepository, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) { var credits []uuid.UUID for _, row := range rows { if row.Amount > 0 { credits = append(credits, row.ID) } } - lots, err := p.repo.ListLotsBySourceTransactions(ctx, credits) + lots, err := repo.ListLotsBySourceTransactions(ctx, credits) if err != nil { return nil, err } diff --git a/internal/repository/wallet_admin_repository.go b/internal/repository/wallet_admin_repository.go new file mode 100644 index 0000000..f3035c2 --- /dev/null +++ b/internal/repository/wallet_admin_repository.go @@ -0,0 +1,126 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" + + "apskel-pos-be/internal/entities" +) + +// WalletCustomer is the customer a dashboard wallet screen is about. +type WalletCustomer struct { + ID uuid.UUID + Name string + Phone *string +} + +// WalletAdminRepository serves what the dashboard adds on top of the customer's own +// wallet view (docs/prd-point-coin.md F7): the raw balances, every lot with balance, +// and the real names behind a ledger row. Nothing here changes a balance. +type WalletAdminRepository interface { + // GetCustomer returns ErrWalletNotFound unless the customer exists and belongs to + // the organization, so one organization can never read another's customers. + GetCustomer(ctx context.Context, organizationID, customerID uuid.UUID) (*WalletCustomer, error) + // GetWallet returns nil, nil for a customer who has never had a wallet. + GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) + // ListLotsWithBalance returns every lot that still holds something, expired or + // not, in K9 spending order. + ListLotsWithBalance(ctx context.Context, customerID uuid.UUID) ([]entities.WalletLot, error) + CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) + UserNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) + OutletNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) +} + +type walletAdminRepository struct { + db *gorm.DB +} + +func NewWalletAdminRepository(db *gorm.DB) WalletAdminRepository { + return &walletAdminRepository{db: db} +} + +func (r *walletAdminRepository) GetCustomer(ctx context.Context, organizationID, customerID uuid.UUID) (*WalletCustomer, error) { + var customers []WalletCustomer + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table("customers"). + Select("id, name, COALESCE(phone_number, phone) AS phone"). + Where("id = ? AND organization_id = ?", customerID, organizationID). + Limit(1). + Scan(&customers).Error + if err != nil { + return nil, fmt.Errorf("failed to get customer: %w", err) + } + if len(customers) == 0 { + return nil, ErrWalletNotFound + } + return &customers[0], nil +} + +func (r *walletAdminRepository) GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) { + var wallets []entities.CustomerWallet + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("customer_id = ?", customerID). + Limit(1). + Find(&wallets).Error + if err != nil { + return nil, fmt.Errorf("failed to get customer wallet: %w", err) + } + if len(wallets) == 0 { + return nil, nil + } + return &wallets[0], nil +} + +func (r *walletAdminRepository) ListLotsWithBalance(ctx context.Context, customerID uuid.UUID) ([]entities.WalletLot, error) { + var lots []entities.WalletLot + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("customer_id = ? AND remaining_amount > 0", customerID). + Order("currency, expires_at NULLS LAST, created_at, id"). + Find(&lots).Error + if err != nil { + return nil, fmt.Errorf("failed to list wallet lots: %w", err) + } + return lots, nil +} + +func (r *walletAdminRepository) CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) { + return r.names(ctx, "customers", ids) +} + +func (r *walletAdminRepository) UserNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) { + return r.names(ctx, "users", ids) +} + +func (r *walletAdminRepository) OutletNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) { + return r.names(ctx, "outlets", ids) +} + +// names looks up the name column of a table by id. The table name is never taken +// from user input. +func (r *walletAdminRepository) names(ctx context.Context, table string, ids []uuid.UUID) (map[uuid.UUID]string, error) { + names := make(map[uuid.UUID]string, len(ids)) + if len(ids) == 0 { + return names, nil + } + var rows []struct { + ID string + Name string + } + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table(table). + Select("id::text AS id, name"). + Where("id IN ?", ids). + Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to look up %s names: %w", table, err) + } + for _, row := range rows { + if id, err := uuid.Parse(row.ID); err == nil { + names[id] = row.Name + } + } + return names, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index 0a5dda6..ad4fbcb 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -54,12 +54,13 @@ type Router struct { productOutletPriceHandler *handler.ProductOutletPriceHandler expenseHandler *handler.ExpenseHandler cashAdvanceHandler *handler.CashAdvanceHandler + walletAdminHandler *handler.WalletAdminHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -105,6 +106,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator), expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator), cashAdvanceHandler: handler.NewCashAdvanceHandler(cashAdvanceService, cashAdvanceValidator), + walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator), redisClient: redisClient, } } @@ -615,6 +617,13 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { } // Campaign Rules + // EnakPoint & EnakCoin wallet of one customer (docs/prd-point-coin.md F7) + marketingCustomers := gamification.Group("/customers") + { + marketingCustomers.GET("/:id/wallet", r.walletAdminHandler.GetCustomerWallet) + marketingCustomers.POST("/:id/wallet/adjust", r.walletAdminHandler.AdjustCustomerWallet) + } + campaignRules := gamification.Group("/campaign-rules") { campaignRules.POST("", r.campaignHandler.CreateCampaignRule) diff --git a/internal/service/wallet_admin_service.go b/internal/service/wallet_admin_service.go new file mode 100644 index 0000000..9ce934e --- /dev/null +++ b/internal/service/wallet_admin_service.go @@ -0,0 +1,69 @@ +package service + +import ( + "context" + "errors" + + "github.com/google/uuid" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" +) + +// WalletAdminService is the dashboard's access to a customer's wallet +// (docs/prd-point-coin.md F7). Every call is scoped to the caller's organization. +type WalletAdminService interface { + GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response + AdjustCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.AdjustCustomerWalletRequest) *contract.Response +} + +type WalletAdminServiceImpl struct { + walletAdminProcessor *processor.WalletAdminProcessor +} + +func NewWalletAdminService(walletAdminProcessor *processor.WalletAdminProcessor) *WalletAdminServiceImpl { + return &WalletAdminServiceImpl{walletAdminProcessor: walletAdminProcessor} +} + +func (s *WalletAdminServiceImpl) GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response { + wallet, err := s.walletAdminProcessor.GetCustomerWallet(ctx, apctx.OrganizationID, customerID, query) + if err != nil { + return walletErrorResponse(err) + } + return contract.BuildSuccessResponse(wallet) +} + +func (s *WalletAdminServiceImpl) AdjustCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.AdjustCustomerWalletRequest) *contract.Response { + result, err := s.walletAdminProcessor.Adjust(ctx, apctx.OrganizationID, apctx.UserID, customerID, models.WalletAdjustment{ + Currency: req.Currency, + Amount: req.Amount, + Reason: req.Reason, + IdempotencyKey: req.IdempotencyKey, + }) + if err != nil { + return walletErrorResponse(err) + } + return contract.BuildSuccessResponse(result) +} + +// walletErrorResponse tells the dashboard's mistakes apart from server failures. +func walletErrorResponse(err error) *contract.Response { + code, message := constants.InternalServerErrorCode, err.Error() + switch { + case errors.Is(err, repository.ErrWalletNotFound): + code, message = constants.NotFoundErrorCode, "customer not found" + case errors.Is(err, repository.ErrWalletInsufficientBalance): + code, message = constants.ValidationErrorCode, "the adjustment would take more than the customer can spend" + case errors.Is(err, processor.ErrInvalidWalletQuery), + errors.Is(err, processor.ErrWalletInvalidEntry), + errors.Is(err, processor.ErrWalletIdempotencyConflict): + code = constants.ValidationErrorCode + } + return contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(code, constants.WalletServiceEntity, message), + }) +} diff --git a/internal/validator/wallet_validator.go b/internal/validator/wallet_validator.go new file mode 100644 index 0000000..862d137 --- /dev/null +++ b/internal/validator/wallet_validator.go @@ -0,0 +1,66 @@ +package validator + +import ( + "errors" + "strings" + "unicode/utf8" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" +) + +// walletAdjustmentReasonLimit is the size of wallet_transactions.reason. +const walletAdjustmentReasonLimit = 255 + +// walletCashOutWords are reasons that describe turning balance into money. EnakPoint +// and EnakCoin can never be cashed out, and an adjustment must not be used to record +// that it happened (docs/prd-point-coin.md K7). Words like "tunai" are left out on +// purpose: "paid in cash but the points never arrived" is a legitimate reason. +var walletCashOutWords = []string{"pencairan", "cairkan", "cash out", "cashout", "withdraw"} + +type WalletValidator interface { + ValidateAdjustRequest(req *contract.AdjustCustomerWalletRequest) (error, string) +} + +type WalletValidatorImpl struct{} + +func NewWalletValidator() *WalletValidatorImpl { + return &WalletValidatorImpl{} +} + +func (v *WalletValidatorImpl) ValidateAdjustRequest(req *contract.AdjustCustomerWalletRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency)) + if req.Currency == "" { + return errors.New("currency is required"), constants.MissingFieldErrorCode + } + if !constants.IsValidWalletCurrency(req.Currency) { + return errors.New("currency must be POINT or COIN"), constants.MalformedFieldErrorCode + } + + if req.Amount == 0 { + return errors.New("amount must not be zero: use a positive amount to add and a negative one to take away"), constants.ValidationErrorCode + } + + req.Reason = strings.TrimSpace(req.Reason) + if req.Reason == "" { + return errors.New("reason is required"), constants.MissingFieldErrorCode + } + if utf8.RuneCountInString(req.Reason) > walletAdjustmentReasonLimit { + return errors.New("reason must be at most 255 characters"), constants.MalformedFieldErrorCode + } + lower := strings.ToLower(req.Reason) + for _, word := range walletCashOutWords { + if strings.Contains(lower, word) { + return errors.New("EnakPoint and EnakCoin cannot be cashed out, so an adjustment cannot be for a cash-out"), constants.ValidationErrorCode + } + } + + if len(req.IdempotencyKey) > 50 { + return errors.New("idempotency_key must be at most 50 characters"), constants.MalformedFieldErrorCode + } + return nil, "" +} diff --git a/internal/validator/wallet_validator_test.go b/internal/validator/wallet_validator_test.go new file mode 100644 index 0000000..b765d87 --- /dev/null +++ b/internal/validator/wallet_validator_test.go @@ -0,0 +1,43 @@ +package validator + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" +) + +func TestWalletValidator_AdjustRequest(t *testing.T) { + v := NewWalletValidator() + + ok := &contract.AdjustCustomerWalletRequest{Currency: " point ", Amount: -25, Reason: " komplain #45 "} + err, _ := v.ValidateAdjustRequest(ok) + assert.NoError(t, err) + assert.Equal(t, constants.WalletCurrencyPoint, ok.Currency, "currency is normalised") + assert.Equal(t, "komplain #45", ok.Reason, "reason is trimmed") + + legit := &contract.AdjustCustomerWalletRequest{Currency: "COIN", Amount: 5, Reason: "Bayar tunai tapi poin tidak masuk"} + err, _ = v.ValidateAdjustRequest(legit) + assert.NoError(t, err, "mentioning a cash payment is not a cash-out") + + for name, req := range map[string]*contract.AdjustCustomerWalletRequest{ + "nil": nil, + "no currency": {Amount: 5, Reason: "x"}, + "unknown currency": {Currency: "GOLD", Amount: 5, Reason: "x"}, + "zero amount": {Currency: "POINT", Amount: 0, Reason: "x"}, + "no reason": {Currency: "POINT", Amount: 5, Reason: " "}, + "reason too long": {Currency: "POINT", Amount: 5, Reason: strings.Repeat("a", 256)}, + "cash-out reason": {Currency: "POINT", Amount: -500, Reason: "Pencairan saldo ke rekening"}, + "cash-out, English": {Currency: "POINT", Amount: -500, Reason: "customer asked to cash out"}, + "long key": {Currency: "POINT", Amount: 5, Reason: "x", IdempotencyKey: strings.Repeat("k", 51)}, + } { + t.Run(name, func(t *testing.T) { + err, code := v.ValidateAdjustRequest(req) + assert.Error(t, err) + assert.NotEmpty(t, code) + }) + } +} -- 2.54.0 From 040780cd2d561096f0664cfcb63cd462901438df Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 10:13:46 +0700 Subject: [PATCH 19/47] feat(wallet): reconcile balances, ledger and lots on a schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the reconciliation of docs/prd-point-coin.md §7.5 (PC-108). One aggregate query per check, across every wallet: - wallet balance = SUM(ledger), per currency, including customers with ledger rows but no wallet row - wallet balance = SUM(lot remaining) - lot original - SUM(allocations) = remaining - SUM(allocations) = |amount| for every deduction - lots created = amount for every addition, which the engine keeps and the other checks rely on The check on payments.points_used waits for that column (PC-305). WalletReconciliationJob runs the checks at startup and every six hours, alongside the omset scheduler. It is silent while the data is consistent. Each discrepancy is logged with its check, customer, object and the expected and actual values, and the organization's admins, owners and managers get a high-priority notification. An organization is notified again only when its set of discrepancies changes. Nothing is corrected automatically. At most 50 discrepancies per check are reported. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 14 ++ .../wallet_reconciliation_repository.go | 160 +++++++++++++ .../wallet_reconciliation_repository_test.go | 141 ++++++++++++ internal/service/wallet_reconciliation_job.go | 212 ++++++++++++++++++ .../service/wallet_reconciliation_job_test.go | 110 +++++++++ 5 files changed, 637 insertions(+) create mode 100644 internal/repository/wallet_reconciliation_repository.go create mode 100644 internal/repository/wallet_reconciliation_repository_test.go create mode 100644 internal/service/wallet_reconciliation_job.go create mode 100644 internal/service/wallet_reconciliation_job_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 3e7f1be..5fba67d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -31,6 +31,7 @@ type App struct { router *router.Router shutdown chan os.Signal omsetScheduler *service.OmsetMilestoneScheduler + walletRecon *service.WalletReconciliationJob } func NewApp(db *gorm.DB, redisClient *redis.Client) *App { @@ -53,6 +54,13 @@ func (a *App) Initialize(cfg *config.Config) error { processors.notificationProcessor, ) + // Checks that wallet balances, ledger and lots agree (docs/prd-point-coin.md §7.5) + a.walletRecon = service.NewWalletReconciliationJob( + repository.NewWalletReconciliationRepository(a.db), + repos.userRepo, + processors.notificationProcessor, + ) + services := a.initServices(processors, repos, cfg) validators := a.initValidators() middleware := a.initMiddleware(services, cfg) @@ -155,6 +163,9 @@ func (a *App) Start(port string) error { if a.omsetScheduler != nil { a.omsetScheduler.Start(5 * time.Minute) } + if a.walletRecon != nil { + a.walletRecon.Start(6 * time.Hour) + } engine := a.router.Init() @@ -194,6 +205,9 @@ func (a *App) Shutdown() { if a.omsetScheduler != nil { a.omsetScheduler.Stop() } + if a.walletRecon != nil { + a.walletRecon.Stop() + } close(a.shutdown) } diff --git a/internal/repository/wallet_reconciliation_repository.go b/internal/repository/wallet_reconciliation_repository.go new file mode 100644 index 0000000..af41535 --- /dev/null +++ b/internal/repository/wallet_reconciliation_repository.go @@ -0,0 +1,160 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// The reconciliation checks of docs/prd-point-coin.md §7.5. +const ( + // Wallet balance = SUM(amount) of the customer's ledger rows, per currency. + WalletCheckBalanceVsLedger = "BALANCE_VS_LEDGER" + // Wallet balance = SUM(remaining_amount) of the customer's lots, per currency. + WalletCheckBalanceVsLots = "BALANCE_VS_LOTS" + // For every lot: original_amount - SUM(allocations) = remaining_amount. + WalletCheckLotVsAllocations = "LOT_VS_ALLOCATIONS" + // For every deduction: SUM(allocations) = |amount|. + WalletCheckDebitVsAllocations = "DEBIT_VS_ALLOCATIONS" + // For every addition: SUM(original_amount) of the lots it created = amount. Not + // listed in §7.5, but the engine keeps it and the other checks rely on it. + WalletCheckCreditVsLots = "CREDIT_VS_LOTS" +) + +// WalletDiscrepancy is one place where the wallet tables disagree with each other. +type WalletDiscrepancy struct { + Check string + OrganizationID uuid.UUID + CustomerID uuid.UUID + Currency string + // The lot or ledger row the check is about. Nil for the per-wallet checks. + ObjectID *uuid.UUID + Expected int64 + Actual int64 +} + +// WalletReconciliationRepository runs the §7.5 checks across every wallet. +type WalletReconciliationRepository interface { + // FindDiscrepancies returns every discrepancy, at most limit per check, so one + // systematic bug cannot produce an unbounded report. + FindDiscrepancies(ctx context.Context, limit int) ([]WalletDiscrepancy, error) +} + +type walletReconciliationRepository struct { + db *gorm.DB +} + +func NewWalletReconciliationRepository(db *gorm.DB) WalletReconciliationRepository { + return &walletReconciliationRepository{db: db} +} + +// Each query returns check, organization_id, customer_id, currency, object_id, +// expected and actual. Aggregates are joined rather than correlated, so each check is +// a handful of scans however many customers there are. +var walletReconciliationQueries = []struct { + check string + sql string +}{ + {WalletCheckBalanceVsLedger, ` + WITH ledger AS ( + SELECT customer_id, currency, MAX(organization_id::text) AS organization_id, SUM(amount) AS total + FROM wallet_transactions GROUP BY customer_id, currency + ), balances AS ( + SELECT customer_id, organization_id::text AS organization_id, 'POINT' AS currency, point_balance AS balance FROM customer_wallets + UNION ALL + SELECT customer_id, organization_id::text, 'COIN', coin_balance FROM customer_wallets + ) + SELECT COALESCE(b.organization_id, l.organization_id) AS organization_id, + COALESCE(b.customer_id, l.customer_id)::text AS customer_id, + COALESCE(b.currency, l.currency) AS currency, + NULL AS object_id, + COALESCE(l.total, 0) AS expected, + COALESCE(b.balance, 0) AS actual + FROM balances b + FULL JOIN ledger l ON l.customer_id = b.customer_id AND l.currency = b.currency + WHERE COALESCE(b.balance, 0) <> COALESCE(l.total, 0) + LIMIT ?`}, + {WalletCheckBalanceVsLots, ` + WITH lots AS ( + SELECT customer_id, currency, MAX(organization_id::text) AS organization_id, SUM(remaining_amount) AS total + FROM wallet_lots GROUP BY customer_id, currency + ), balances AS ( + SELECT customer_id, organization_id::text AS organization_id, 'POINT' AS currency, point_balance AS balance FROM customer_wallets + UNION ALL + SELECT customer_id, organization_id::text, 'COIN', coin_balance FROM customer_wallets + ) + SELECT COALESCE(b.organization_id, l.organization_id) AS organization_id, + COALESCE(b.customer_id, l.customer_id)::text AS customer_id, + COALESCE(b.currency, l.currency) AS currency, + NULL AS object_id, + COALESCE(l.total, 0) AS expected, + COALESCE(b.balance, 0) AS actual + FROM balances b + FULL JOIN lots l ON l.customer_id = b.customer_id AND l.currency = b.currency + WHERE COALESCE(b.balance, 0) <> COALESCE(l.total, 0) + LIMIT ?`}, + {WalletCheckLotVsAllocations, ` + SELECT l.organization_id::text AS organization_id, l.customer_id::text AS customer_id, l.currency, + l.id::text AS object_id, + l.original_amount - COALESCE(a.total, 0) AS expected, + l.remaining_amount AS actual + FROM wallet_lots l + LEFT JOIN (SELECT lot_id, SUM(amount) AS total FROM wallet_lot_allocations GROUP BY lot_id) a ON a.lot_id = l.id + WHERE l.original_amount - COALESCE(a.total, 0) <> l.remaining_amount + LIMIT ?`}, + {WalletCheckDebitVsAllocations, ` + SELECT t.organization_id::text AS organization_id, t.customer_id::text AS customer_id, t.currency, + t.id::text AS object_id, + -t.amount AS expected, + COALESCE(a.total, 0) AS actual + FROM wallet_transactions t + LEFT JOIN (SELECT transaction_id, SUM(amount) AS total FROM wallet_lot_allocations GROUP BY transaction_id) a ON a.transaction_id = t.id + WHERE t.amount < 0 AND -t.amount <> COALESCE(a.total, 0) + LIMIT ?`}, + {WalletCheckCreditVsLots, ` + SELECT t.organization_id::text AS organization_id, t.customer_id::text AS customer_id, t.currency, + t.id::text AS object_id, + t.amount AS expected, + COALESCE(l.total, 0) AS actual + FROM wallet_transactions t + LEFT JOIN (SELECT source_transaction_id, SUM(original_amount) AS total FROM wallet_lots GROUP BY source_transaction_id) l ON l.source_transaction_id = t.id + WHERE t.amount > 0 AND t.amount <> COALESCE(l.total, 0) + LIMIT ?`}, +} + +func (r *walletReconciliationRepository) FindDiscrepancies(ctx context.Context, limit int) ([]WalletDiscrepancy, error) { + db := DBFromContext(ctx, r.db).WithContext(ctx) + var found []WalletDiscrepancy + for _, q := range walletReconciliationQueries { + var rows []struct { + OrganizationID string + CustomerID string + Currency string + ObjectID *string + Expected int64 + Actual int64 + } + if err := db.Raw(q.sql, limit).Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("wallet reconciliation check %s failed: %w", q.check, err) + } + for _, row := range rows { + d := WalletDiscrepancy{ + Check: q.check, + Currency: row.Currency, + Expected: row.Expected, + Actual: row.Actual, + } + d.OrganizationID, _ = uuid.Parse(row.OrganizationID) + d.CustomerID, _ = uuid.Parse(row.CustomerID) + if row.ObjectID != nil { + if id, err := uuid.Parse(*row.ObjectID); err == nil { + d.ObjectID = &id + } + } + found = append(found, d) + } + } + return found, nil +} diff --git a/internal/repository/wallet_reconciliation_repository_test.go b/internal/repository/wallet_reconciliation_repository_test.go new file mode 100644 index 0000000..4b530f4 --- /dev/null +++ b/internal/repository/wallet_reconciliation_repository_test.go @@ -0,0 +1,141 @@ +package repository_test + +import ( + "context" + "os" + "sort" + "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/processor" + "apskel-pos-be/internal/repository" +) + +// Builds consistent wallets through the engine, checks the reconciliation is silent, +// then breaks each §7.5 invariant for a different customer and checks each break is +// found by the right checks and nothing else is. Needs TEST_DATABASE_URL pointing at +// a migrated database; see wallet_repository_test.go. +func TestWalletReconciliation_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() + names := []string{"clean", "balance", "lot", "allocation", "credit"} + customers := map[string]uuid.UUID{} + var ids []uuid.UUID + require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'recon test', 'basic')`, org).Error) + for _, n := range names { + customers[n] = uuid.New() + ids = append(ids, customers[n]) + require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, ?)`, customers[n], org, n).Error) + } + t.Cleanup(func() { + db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, ids) + db.Exec(`DELETE FROM wallet_lot_allocations WHERE transaction_id IN (SELECT id FROM wallet_transactions WHERE customer_id IN ?)`, ids) + db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, ids) + db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, ids) + db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, ids) + db.Exec(`DELETE FROM customers WHERE id IN ?`, ids) + db.Exec(`DELETE FROM organizations WHERE id = ?`, org) + }) + + // Every customer: +100 in two lots, +30 coins, -70 points across both lots. + wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) + txm := repository.NewTxManager(db) + for _, id := range ids { + require.NoError(t, txm.WithTransaction(ctx, func(ctx context.Context) error { + if _, err := wallet.Credit(ctx, processor.WalletCreditInput{ + WalletEntry: processor.WalletEntry{CustomerID: id, Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypeMigration, Amount: 100, ReferenceType: constants.WalletRefTypeLegacyPoints, + ReferenceID: uuid.New(), Description: "Saldo awal"}, + Lots: []processor.WalletLotInput{{Amount: 60}, {Amount: 40}}, + }); err != nil { + return err + } + if _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ + CustomerID: id, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration, + Amount: 30, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: id, Description: "Saldo awal"}}); err != nil { + return err + } + outlet := uuid.New() + _, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{ + CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, + Amount: 70, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet, + Description: "Bayar"}}) + return err + })) + } + + recon := repository.NewWalletReconciliationRepository(db) + // Other packages' tests may share the database, so only these customers count. + checksByCustomer := func() map[string][]string { + t.Helper() + found, err := recon.FindDiscrepancies(ctx, 1000) + require.NoError(t, err) + byName := map[uuid.UUID]string{} + for n, id := range customers { + byName[id] = n + } + out := map[string][]string{} + for _, d := range found { + if n, ok := byName[d.CustomerID]; ok { + assert.Equal(t, org, d.OrganizationID) + out[n] = append(out[n], d.Check) + } + } + for n := range out { + sort.Strings(out[n]) + } + return out + } + + assert.Empty(t, checksByCustomer(), "consistent data reports nothing") + + exec := func(q string, args ...any) { + t.Helper() + require.NoError(t, db.Exec(q, args...).Error) + } + // A balance moved without a ledger row or a lot. + exec(`UPDATE customer_wallets SET point_balance = point_balance + 5 WHERE customer_id = ?`, customers["balance"]) + // A lot's remainder changed without an allocation. + exec(`UPDATE wallet_lots SET remaining_amount = remaining_amount - 1 + WHERE id = (SELECT id FROM wallet_lots WHERE customer_id = ? AND remaining_amount > 0 LIMIT 1)`, customers["lot"]) + // An allocation lost. + exec(`DELETE FROM wallet_lot_allocations WHERE (transaction_id, lot_id) IN ( + SELECT a.transaction_id, a.lot_id FROM wallet_lot_allocations a + JOIN wallet_transactions t ON t.id = a.transaction_id WHERE t.customer_id = ? LIMIT 1)`, customers["allocation"]) + // A credit whose lot was never written, with the balance moved to match the ledger. + exec(`INSERT INTO wallet_transactions (organization_id, customer_id, currency, type, amount, balance_after, reference_type, reference_id, description) + VALUES (?, ?, 'COIN', 'MIGRATION', 10, 40, 'LEGACY_TOKENS', ?, 'x')`, org, customers["credit"], customers["credit"]) + exec(`UPDATE customer_wallets SET coin_balance = coin_balance + 10 WHERE customer_id = ?`, customers["credit"]) + + assert.Equal(t, map[string][]string{ + "balance": {repository.WalletCheckBalanceVsLedger, repository.WalletCheckBalanceVsLots}, + "lot": {repository.WalletCheckBalanceVsLots, repository.WalletCheckLotVsAllocations}, + "allocation": {repository.WalletCheckDebitVsAllocations, repository.WalletCheckLotVsAllocations}, + "credit": {repository.WalletCheckBalanceVsLots, repository.WalletCheckCreditVsLots}, + }, checksByCustomer(), "each break is found by exactly the checks it violates, and the clean customer by none") + + // The per-check limit caps the report. + found, err := recon.FindDiscrepancies(ctx, 1) + require.NoError(t, err) + perCheck := map[string]int{} + for _, d := range found { + perCheck[d.Check]++ + } + for check, n := range perCheck { + assert.LessOrEqual(t, n, 1, check) + } +} diff --git a/internal/service/wallet_reconciliation_job.go b/internal/service/wallet_reconciliation_job.go new file mode 100644 index 0000000..2b9aebd --- /dev/null +++ b/internal/service/wallet_reconciliation_job.go @@ -0,0 +1,212 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "sync" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +const ( + defaultWalletReconciliationInterval = 6 * time.Hour + // Per check, so one systematic bug cannot flood the log or the notification. + walletReconciliationLimit = 50 +) + +type walletDiscrepancyFinder interface { + FindDiscrepancies(ctx context.Context, limit int) ([]repository.WalletDiscrepancy, error) +} + +type organizationUserLister interface { + GetByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error) +} + +type notificationSender interface { + Send(ctx context.Context, req *models.SendNotificationRequest) (*models.NotificationResponse, error) +} + +// WalletReconciliationJob periodically runs the §7.5 checks of +// docs/prd-point-coin.md over every wallet (PC-108). It is silent while the data is +// consistent. When it finds a discrepancy it logs each one and notifies the admins, +// owners and managers of the organization concerned. +// +// An organization is notified again only when its set of discrepancies changes, so an +// unfixed problem does not page the same people every run. That memory is in-process: +// a restart notifies once more, and each running instance keeps its own. +type WalletReconciliationJob struct { + finder walletDiscrepancyFinder + users organizationUserLister + notifier notificationSender + + mu sync.Mutex + notified map[uuid.UUID]string // organization -> fingerprint last notified + stopCh chan struct{} + stopOnce sync.Once +} + +func NewWalletReconciliationJob(finder walletDiscrepancyFinder, users organizationUserLister, notifier notificationSender) *WalletReconciliationJob { + return &WalletReconciliationJob{ + finder: finder, + users: users, + notifier: notifier, + notified: make(map[uuid.UUID]string), + stopCh: make(chan struct{}), + } +} + +// Start runs the checks once now and then every interval, in the background. +func (j *WalletReconciliationJob) Start(interval time.Duration) { + if interval <= 0 { + interval = defaultWalletReconciliationInterval + } + go func() { + j.runLogged() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + j.runLogged() + case <-j.stopCh: + return + } + } + }() + logger.NonContext.Infof("Wallet reconciliation job started (interval: %s)", interval) +} + +func (j *WalletReconciliationJob) Stop() { + j.stopOnce.Do(func() { close(j.stopCh) }) +} + +func (j *WalletReconciliationJob) runLogged() { + if _, err := j.RunOnce(context.Background()); err != nil { + logger.NonContext.Error("Wallet reconciliation failed to run", err) + } +} + +// RunOnce runs every check, reports what it finds, and returns it. +func (j *WalletReconciliationJob) RunOnce(ctx context.Context) ([]repository.WalletDiscrepancy, error) { + found, err := j.finder.FindDiscrepancies(ctx, walletReconciliationLimit) + if err != nil { + return nil, err + } + + byOrg := make(map[uuid.UUID][]repository.WalletDiscrepancy) + for _, d := range found { + fields := map[string]interface{}{ + "check": d.Check, + "organization_id": d.OrganizationID.String(), + "customer_id": d.CustomerID.String(), + "currency": d.Currency, + "expected": d.Expected, + "actual": d.Actual, + } + if d.ObjectID != nil { + fields["object_id"] = d.ObjectID.String() + } + logger.NonContext.WarnWithFields("Wallet reconciliation found a discrepancy", fields, nil) + byOrg[d.OrganizationID] = append(byOrg[d.OrganizationID], d) + } + + j.mu.Lock() + defer j.mu.Unlock() + // Organizations that are clean again are forgotten, so a later problem notifies. + for org := range j.notified { + if _, still := byOrg[org]; !still { + delete(j.notified, org) + } + } + for org, discrepancies := range byOrg { + fingerprint := walletDiscrepancyFingerprint(discrepancies) + if j.notified[org] == fingerprint { + continue + } + if err := j.notify(ctx, org, discrepancies); err != nil { + logger.NonContext.Error(fmt.Sprintf("Wallet reconciliation could not notify organization %s", org), err) + continue + } + j.notified[org] = fingerprint + } + return found, nil +} + +func (j *WalletReconciliationJob) notify(ctx context.Context, organizationID uuid.UUID, discrepancies []repository.WalletDiscrepancy) error { + if organizationID == uuid.Nil { + return fmt.Errorf("discrepancy without an organization") + } + users, err := j.users.GetByOrganizationID(ctx, organizationID) + if err != nil { + return err + } + var receivers []uuid.UUID + for _, u := range users { + switch u.Role { + case entities.RoleAdmin, entities.RoleOwner, entities.RoleManager: + receivers = append(receivers, u.ID) + } + } + if len(receivers) == 0 { + return nil + } + + perCheck := map[string]int{} + customers := map[string]bool{} + for _, d := range discrepancies { + perCheck[d.Check]++ + customers[d.CustomerID.String()] = true + } + customerIDs := make([]string, 0, len(customers)) + for id := range customers { + customerIDs = append(customerIDs, id) + } + sort.Strings(customerIDs) + + _, err = j.notifier.Send(ctx, &models.SendNotificationRequest{ + Title: "Selisih saldo EnakPoint/EnakCoin terdeteksi", + Body: fmt.Sprintf("Pemeriksaan rutin menemukan %d selisih pada saldo %d customer. Saldo belum dikoreksi otomatis; tim teknis perlu memeriksanya.", + len(discrepancies), len(customerIDs)), + Type: "system", + Category: "wallet_reconciliation", + Priority: entities.NotificationPriorityHigh, + NotifiableType: "organization", + NotifiableID: &organizationID, + ReceiverIDs: receivers, + Data: map[string]interface{}{ + "organization_id": organizationID.String(), + "discrepancies": len(discrepancies), + "per_check": perCheck, + "customer_ids": customerIDs, + }, + }) + return err +} + +// walletDiscrepancyFingerprint identifies a set of discrepancies regardless of order. +func walletDiscrepancyFingerprint(discrepancies []repository.WalletDiscrepancy) string { + keys := make([]string, 0, len(discrepancies)) + for _, d := range discrepancies { + object := "" + if d.ObjectID != nil { + object = d.ObjectID.String() + } + keys = append(keys, fmt.Sprintf("%s|%s|%s|%s|%d|%d", d.Check, d.CustomerID, d.Currency, object, d.Expected, d.Actual)) + } + sort.Strings(keys) + h := sha256.New() + for _, k := range keys { + h.Write([]byte(k)) + h.Write([]byte{'\n'}) + } + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/service/wallet_reconciliation_job_test.go b/internal/service/wallet_reconciliation_job_test.go new file mode 100644 index 0000000..c12c8e6 --- /dev/null +++ b/internal/service/wallet_reconciliation_job_test.go @@ -0,0 +1,110 @@ +package service + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +type discrepancyFinderFake struct { + found []repository.WalletDiscrepancy +} + +func (f *discrepancyFinderFake) FindDiscrepancies(context.Context, int) ([]repository.WalletDiscrepancy, error) { + return f.found, nil +} + +type orgUsersFake map[uuid.UUID][]*entities.User + +func (f orgUsersFake) GetByOrganizationID(_ context.Context, org uuid.UUID) ([]*entities.User, error) { + return f[org], nil +} + +type notifierFake struct { + sent []*models.SendNotificationRequest +} + +func (f *notifierFake) Send(_ context.Context, req *models.SendNotificationRequest) (*models.NotificationResponse, error) { + f.sent = append(f.sent, req) + return &models.NotificationResponse{}, nil +} + +func TestWalletReconciliationJob(t *testing.T) { + logger.Setup("fatal", "json") + org := uuid.New() + admin, owner, manager, cashier := uuid.New(), uuid.New(), uuid.New(), uuid.New() + users := orgUsersFake{org: { + {ID: admin, Role: entities.RoleAdmin}, + {ID: owner, Role: entities.RoleOwner}, + {ID: manager, Role: entities.RoleManager}, + {ID: cashier, Role: entities.RoleCashier}, + }} + finder := &discrepancyFinderFake{} + notifier := ¬ifierFake{} + job := NewWalletReconciliationJob(finder, users, notifier) + ctx := context.Background() + + // Consistent data: nothing reported. + found, err := job.RunOnce(ctx) + require.NoError(t, err) + assert.Empty(t, found) + assert.Empty(t, notifier.sent) + + // A discrepancy notifies the organization's admins, owners and managers. + customer := uuid.New() + lot := uuid.New() + finder.found = []repository.WalletDiscrepancy{ + {Check: repository.WalletCheckBalanceVsLots, OrganizationID: org, CustomerID: customer, Currency: "POINT", Expected: 100, Actual: 105}, + {Check: repository.WalletCheckLotVsAllocations, OrganizationID: org, CustomerID: customer, Currency: "POINT", ObjectID: &lot, Expected: 50, Actual: 55}, + } + found, err = job.RunOnce(ctx) + require.NoError(t, err) + assert.Len(t, found, 2) + require.Len(t, notifier.sent, 1) + sent := notifier.sent[0] + assert.ElementsMatch(t, []uuid.UUID{admin, owner, manager}, sent.ReceiverIDs, "cashiers are not told") + assert.Equal(t, &org, sent.NotifiableID) + assert.Equal(t, 2, sent.Data["discrepancies"]) + assert.Equal(t, []string{customer.String()}, sent.Data["customer_ids"]) + assert.Equal(t, entities.NotificationPriorityHigh, sent.Priority) + + // The same problem, still unfixed and in a different order, does not notify again. + finder.found = []repository.WalletDiscrepancy{finder.found[1], finder.found[0]} + _, err = job.RunOnce(ctx) + require.NoError(t, err) + assert.Len(t, notifier.sent, 1) + + // A changed problem does. + finder.found = finder.found[:1] + _, err = job.RunOnce(ctx) + require.NoError(t, err) + assert.Len(t, notifier.sent, 2) + + // Once clean the organization is forgotten, so the same problem coming back + // notifies again. + previous := finder.found + finder.found = nil + _, err = job.RunOnce(ctx) + require.NoError(t, err) + assert.Len(t, notifier.sent, 2) + finder.found = previous + _, err = job.RunOnce(ctx) + require.NoError(t, err) + assert.Len(t, notifier.sent, 3) +} + +func TestWalletReconciliationJobStartStop(t *testing.T) { + logger.Setup("fatal", "json") + job := NewWalletReconciliationJob(&discrepancyFinderFake{}, orgUsersFake{}, ¬ifierFake{}) + job.Start(0) + job.Stop() + job.Stop() // stopping twice is harmless +} -- 2.54.0 From 39e47ff0e6cf23e1f7b148eda78fd3dcab2918ba Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 10:23:08 +0700 Subject: [PATCH 20/47] feat(loyalty): typed loyalty settings with change history Adds LoyaltySettingsProcessor (docs/prd-point-coin.md F1, F2, F12, PC-109). Reading returns typed settings for an outlet (earning per currency, paying with EnakPoint) and for an organization (point value, exchange rate, transfers, and the expiry settings awaiting note N4). A key that was never set takes the PRD default. A stored value that is unusable, such as an earn_per_amount of 0 that would divide by zero, also falls back to the default and is logged, so a bad row never reaches a calculation. Writing takes the whole settings struct, validates every rule in the PRD before touching the database, and stores and records in loyalty_setting_changes only the keys whose effective value changes: old value (NULL while it was on its default), new value, and who changed it. Clearing a limit deletes the stored value. Each save runs in one transaction under an advisory lock per outlet or organization, so two saves at once cannot both compute their change from the same old value. The outlet must belong to the caller's organization. Every key is described once (key, default, valid range, bound field), and reading, validating and diffing all use that description. GET /customer/wallet now reads the point value through this processor; the minimal organization settings repository from PC-106 is removed. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 8 +- internal/constants/loyalty.go | 71 ++++ internal/constants/wallet.go | 7 - internal/entities/entities.go | 1 + internal/entities/loyalty_setting_change.go | 34 ++ internal/handler/customer_wallet_db_test.go | 2 +- internal/handler/wallet_admin_db_test.go | 2 +- internal/models/loyalty.go | 81 ++++ .../processor/loyalty_settings_processor.go | 399 ++++++++++++++++++ .../loyalty_settings_processor_test.go | 322 ++++++++++++++ internal/processor/wallet_query_processor.go | 30 +- .../processor/wallet_query_processor_test.go | 7 +- .../repository/loyalty_settings_repository.go | 176 ++++++++ .../loyalty_settings_repository_test.go | 149 +++++++ .../organization_setting_repository.go | 41 -- 15 files changed, 1250 insertions(+), 80 deletions(-) create mode 100644 internal/constants/loyalty.go create mode 100644 internal/entities/loyalty_setting_change.go create mode 100644 internal/models/loyalty.go create mode 100644 internal/processor/loyalty_settings_processor.go create mode 100644 internal/processor/loyalty_settings_processor_test.go create mode 100644 internal/repository/loyalty_settings_repository.go create mode 100644 internal/repository/loyalty_settings_repository_test.go delete mode 100644 internal/repository/organization_setting_repository.go diff --git a/internal/app/app.go b/internal/app/app.go index 5fba67d..389a947 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -265,7 +265,7 @@ type repositories struct { cashAdvanceRepo *repository.CashAdvanceRepositoryImpl walletRepo repository.WalletRepository walletQueryRepo repository.WalletQueryRepository - organizationSettingRepo repository.OrganizationSettingRepository + loyaltySettingsRepo repository.LoyaltySettingsRepository } func (a *App) initRepositories() *repositories { @@ -323,7 +323,7 @@ func (a *App) initRepositories() *repositories { cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db), walletRepo: repository.NewWalletRepository(a.db), walletQueryRepo: repository.NewWalletQueryRepository(a.db), - organizationSettingRepo: repository.NewOrganizationSettingRepository(a.db), + loyaltySettingsRepo: repository.NewLoyaltySettingsRepository(a.db), } } @@ -374,6 +374,7 @@ type processors struct { cashAdvanceProcessor *processor.CashAdvanceProcessorImpl walletProcessor *processor.WalletProcessor walletAdminProcessor *processor.WalletAdminProcessor + loyaltySettingsProcessor *processor.LoyaltySettingsProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -418,7 +419,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo), customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()), - customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, repos.organizationSettingRepo), repos.gameRepo), + customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)), repos.gameRepo), otpProcessor: otpProcessor, fileClient: fileClient, inventoryMovementService: inventoryMovementService, @@ -428,6 +429,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo), cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo), walletProcessor: processor.NewWalletProcessor(repos.walletRepo), + loyaltySettingsProcessor: processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager), walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), } } diff --git a/internal/constants/loyalty.go b/internal/constants/loyalty.go new file mode 100644 index 0000000..14ac2e6 --- /dev/null +++ b/internal/constants/loyalty.go @@ -0,0 +1,71 @@ +package constants + +// Loyalty setting keys (docs/prd-point-coin.md F1, F2, F12). Outlet keys live in +// outlet_settings and organization keys in organization_settings. A key that was +// never set takes the default in the PRD. + +// Per outlet (F1): what an order earns, and whether EnakPoint can pay. +const ( + LoyaltyPointEnabledKey = "loyalty.point.enabled" + LoyaltyPointEarnPerAmountKey = "loyalty.point.earn_per_amount" + LoyaltyPointEarnValueKey = "loyalty.point.earn_value" + LoyaltyPointMinOrderAmountKey = "loyalty.point.min_order_amount" + LoyaltyPointMaxPerOrderKey = "loyalty.point.max_per_order" + + LoyaltyCoinEnabledKey = "loyalty.coin.enabled" + LoyaltyCoinEarnPerAmountKey = "loyalty.coin.earn_per_amount" + LoyaltyCoinEarnValueKey = "loyalty.coin.earn_value" + LoyaltyCoinMinOrderAmountKey = "loyalty.coin.min_order_amount" + LoyaltyCoinMaxPerOrderKey = "loyalty.coin.max_per_order" + + LoyaltyPointAcceptPaymentKey = "loyalty.point.accept_payment" + LoyaltyPointMinPaymentPointsKey = "loyalty.point.min_payment_points" + LoyaltyPointMaxPaymentPercentKey = "loyalty.point.max_payment_percent" +) + +// Per organization (F2, F12): the value of EnakPoint, the exchange rate, transfers and +// expiry, which must be the same in every outlet. +const ( + LoyaltyPointValueKey = "loyalty.point.value" + + LoyaltyExchangeCoinAmountKey = "loyalty.exchange.coin_amount" + LoyaltyExchangePointAmountKey = "loyalty.exchange.point_amount" + + LoyaltyTransferEnabledKey = "loyalty.transfer.enabled" + LoyaltyTransferMinAmountKey = "loyalty.transfer.min_amount" + LoyaltyTransferMaxPerTransactionKey = "loyalty.transfer.max_per_transaction" + LoyaltyTransferDailyLimitKey = "loyalty.transfer.daily_limit" + + // Suffixes after "loyalty.point." or "loyalty.coin.". + LoyaltyExpiryEnabledSuffix = "expiry_enabled" + LoyaltyExpiryPeriodSuffix = "expiry_period" + LoyaltyExpiryUnitSuffix = "expiry_unit" + LoyaltyExpiryEndOfMonthSuffix = "expiry_end_of_month" + LoyaltyExpiryReminderDaysSuffix = "expiry_reminder_days" +) + +// Units of loyalty.{point,coin}.expiry_unit. +const ( + LoyaltyExpiryUnitDay = "DAY" + LoyaltyExpiryUnitMonth = "MONTH" +) + +// Defaults from the PRD. +const ( + LoyaltyPointEarnPerAmountDefault = int64(100) + LoyaltyCoinEarnPerAmountDefault = int64(25000) + LoyaltyEarnValueDefault = int64(1) + + LoyaltyMinPaymentPointsDefault = int64(1) + LoyaltyMaxPaymentPercentDefault = int64(100) + + LoyaltyPointValueDefault = int64(1) + LoyaltyExchangeAmountDefault = int64(1) + + LoyaltyTransferEnabledDefault = true + LoyaltyTransferMinAmountDefault = int64(1) + + LoyaltyExpiryPeriodDefault = int64(12) + LoyaltyExpiryUnitDefault = LoyaltyExpiryUnitMonth + LoyaltyExpiryReminderDaysDefault = int64(7) +) diff --git a/internal/constants/wallet.go b/internal/constants/wallet.go index 6182154..2892fdd 100644 --- a/internal/constants/wallet.go +++ b/internal/constants/wallet.go @@ -42,10 +42,3 @@ const ( WalletRefTypeLegacyTokens = "LEGACY_TOKENS" WalletRefTypeRewardRedemption = "REWARD_REDEMPTION" ) - -// Organization loyalty settings (docs/prd-point-coin.md F2), stored in -// organization_settings. A key that was never set takes its default. -const ( - LoyaltyPointValueKey = "loyalty.point.value" - LoyaltyPointValueDefault = int64(1) -) diff --git a/internal/entities/entities.go b/internal/entities/entities.go index e26763b..8ee2e10 100644 --- a/internal/entities/entities.go +++ b/internal/entities/entities.go @@ -49,6 +49,7 @@ func GetAllEntities() []interface{} { &WalletTransaction{}, &WalletLot{}, &WalletLotAllocation{}, + &LoyaltySettingChange{}, } } diff --git a/internal/entities/loyalty_setting_change.go b/internal/entities/loyalty_setting_change.go new file mode 100644 index 0000000..db5e8f2 --- /dev/null +++ b/internal/entities/loyalty_setting_change.go @@ -0,0 +1,34 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// LoyaltySettingChange records one change to a loyalty setting: which key, from what, +// to what, by whom (docs/prd-point-coin.md F2). Append-only. +type LoyaltySettingChange struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` + // Nil for an organization setting. + OutletID *uuid.UUID `gorm:"type:uuid" json:"outlet_id"` + Key string `gorm:"not null;size:100" json:"key"` + // Nil when the key had no stored value, or has none any more. + OldValue *string `gorm:"type:text" json:"old_value"` + NewValue *string `gorm:"type:text" json:"new_value"` + ChangedBy uuid.UUID `gorm:"type:uuid;not null" json:"changed_by"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +func (c *LoyaltySettingChange) BeforeCreate(tx *gorm.DB) error { + if c.ID == uuid.Nil { + c.ID = uuid.New() + } + return nil +} + +func (LoyaltySettingChange) TableName() string { + return "loyalty_setting_changes" +} diff --git a/internal/handler/customer_wallet_db_test.go b/internal/handler/customer_wallet_db_test.go index e3713b2..d9e2467 100644 --- a/internal/handler/customer_wallet_db_test.go +++ b/internal/handler/customer_wallet_db_test.go @@ -97,7 +97,7 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) { })) h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor( - processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), repository.NewOrganizationSettingRepository(db)), + processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), nil, ))) gin.SetMode(gin.TestMode) diff --git a/internal/handler/wallet_admin_db_test.go b/internal/handler/wallet_admin_db_test.go index 85d0adc..1e1a690 100644 --- a/internal/handler/wallet_admin_db_test.go +++ b/internal/handler/wallet_admin_db_test.go @@ -186,7 +186,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) { // The customer's own history shows the adjustment too, with the reason in the // description and without the admin's identity fields. customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor( - processor.NewWalletQueryProcessor(queryRepo, repository.NewOrganizationSettingRepository(db)), nil))) + processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), nil))) crouter := gin.New() crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions) rec := httptest.NewRecorder() diff --git a/internal/models/loyalty.go b/internal/models/loyalty.go new file mode 100644 index 0000000..69d0970 --- /dev/null +++ b/internal/models/loyalty.go @@ -0,0 +1,81 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// OutletLoyaltySettings are an outlet's loyalty settings (docs/prd-point-coin.md F1). +type OutletLoyaltySettings struct { + Point LoyaltyEarnSettings `json:"point"` + Coin LoyaltyEarnSettings `json:"coin"` + // Paying with EnakPoint. EnakCoin cannot pay, so it has no counterpart. + PointPayment LoyaltyPointPaymentSettings `json:"point_payment"` +} + +// LoyaltyEarnSettings is how much of one currency an order earns: +// floor(basis / EarnPerAmount) × EarnValue, nothing below MinOrderAmount, and at most +// MaxPerOrder when set. +type LoyaltyEarnSettings struct { + Enabled bool `json:"enabled"` + EarnPerAmount int64 `json:"earn_per_amount"` + EarnValue int64 `json:"earn_value"` + MinOrderAmount int64 `json:"min_order_amount"` + MaxPerOrder *int64 `json:"max_per_order"` +} + +type LoyaltyPointPaymentSettings struct { + AcceptPayment bool `json:"accept_payment"` + MinPaymentPoints int64 `json:"min_payment_points"` + // Largest share of the order total, 0–100, that EnakPoint may pay. + MaxPaymentPercent int64 `json:"max_payment_percent"` +} + +// OrganizationLoyaltySettings are the loyalty settings shared by every outlet of an +// organization (docs/prd-point-coin.md F2, F12). +type OrganizationLoyaltySettings struct { + // Rupiah value of one EnakPoint when paying. + PointValue int64 `json:"point_value"` + // CoinAmount EnakCoin exchange into PointAmount EnakPoint. + Exchange LoyaltyExchangeSettings `json:"exchange"` + Transfer LoyaltyTransferSettings `json:"transfer"` + PointExpiry LoyaltyExpirySettings `json:"point_expiry"` + CoinExpiry LoyaltyExpirySettings `json:"coin_expiry"` +} + +type LoyaltyExchangeSettings struct { + CoinAmount int64 `json:"coin_amount"` + PointAmount int64 `json:"point_amount"` +} + +type LoyaltyTransferSettings struct { + Enabled bool `json:"enabled"` + MinAmount int64 `json:"min_amount"` + MaxPerTransaction *int64 `json:"max_per_transaction"` + DailyLimit *int64 `json:"daily_limit"` +} + +// LoyaltyExpirySettings is how long one currency lasts once received. The expiry +// model is still open (note N4); these are only the stored settings. +type LoyaltyExpirySettings struct { + Enabled bool `json:"enabled"` + Period int64 `json:"period"` + // DAY or MONTH. + Unit string `json:"unit"` + EndOfMonth bool `json:"end_of_month"` + ReminderDays int64 `json:"reminder_days"` +} + +// LoyaltySettingChange is one row of the loyalty settings history. +type LoyaltySettingChange struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + Key string `json:"key"` + // Nil when the key had no stored value, that is it was on its default. + OldValue *string `json:"old_value"` + NewValue *string `json:"new_value"` + ChangedBy uuid.UUID `json:"changed_by"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/processor/loyalty_settings_processor.go b/internal/processor/loyalty_settings_processor.go new file mode 100644 index 0000000..330456f --- /dev/null +++ b/internal/processor/loyalty_settings_processor.go @@ -0,0 +1,399 @@ +package processor + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// ErrInvalidLoyaltySettings wraps every rejection of a setting that breaks the rules in +// docs/prd-point-coin.md F1, F2 and F12. +var ErrInvalidLoyaltySettings = errors.New("invalid loyalty settings") + +// LoyaltySettingsProcessor reads and writes the loyalty settings of outlets (F1) and +// organizations (F2, F12). Reads always return every setting, with the PRD default for +// a key that was never set, so callers never handle a missing value. Every write +// records what changed in loyalty_setting_changes. +type LoyaltySettingsProcessor struct { + repo repository.LoyaltySettingsRepository + tx TxRunner +} + +func NewLoyaltySettingsProcessor(repo repository.LoyaltySettingsRepository, tx TxRunner) *LoyaltySettingsProcessor { + return &LoyaltySettingsProcessor{repo: repo, tx: tx} +} + +// Outlet returns an outlet's settings. +func (p *LoyaltySettingsProcessor) Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) { + raw, err := p.repo.GetOutletValues(ctx, outletID) + if err != nil { + return nil, err + } + var s models.OutletLoyaltySettings + loadLoyaltyFields(outletLoyaltyFields(&s), raw, "outlet "+outletID.String()) + return &s, nil +} + +// Organization returns an organization's settings. +func (p *LoyaltySettingsProcessor) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) { + raw, err := p.repo.GetOrganizationValues(ctx, organizationID) + if err != nil { + return nil, err + } + var s models.OrganizationLoyaltySettings + loadLoyaltyFields(organizationLoyaltyFields(&s), raw, "organization "+organizationID.String()) + return &s, nil +} + +// PointValue is the rupiah value of one EnakPoint in an organization. +func (p *LoyaltySettingsProcessor) PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) { + s, err := p.Organization(ctx, organizationID) + if err != nil { + return 0, err + } + return s.PointValue, nil +} + +// UpdateOutlet replaces an outlet's settings and returns what changed. The outlet must +// belong to the organization; otherwise repository.ErrLoyaltyOutletNotFound. +func (p *LoyaltySettingsProcessor) UpdateOutlet(ctx context.Context, organizationID, outletID, changedBy uuid.UUID, settings models.OutletLoyaltySettings) ([]models.LoyaltySettingChange, error) { + if err := validateLoyaltyFields(outletLoyaltyFields(&settings)); err != nil { + return nil, err + } + var changes []models.LoyaltySettingChange + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + owner, err := p.repo.OutletOrganizationID(ctx, outletID) + if err != nil { + return err + } + if owner != organizationID { + return repository.ErrLoyaltyOutletNotFound + } + if err := p.repo.LockScope(ctx, outletID); err != nil { + return err + } + raw, err := p.repo.GetOutletValues(ctx, outletID) + if err != nil { + return err + } + var current models.OutletLoyaltySettings + loadLoyaltyFields(outletLoyaltyFields(¤t), raw, "outlet "+outletID.String()) + + changes, err = p.apply(ctx, organizationID, &outletID, changedBy, raw, + outletLoyaltyFields(¤t), outletLoyaltyFields(&settings), + func(key string, value *string) error { return p.repo.SetOutletValue(ctx, outletID, key, value) }) + return err + }) + return changes, err +} + +// UpdateOrganization replaces an organization's settings and returns what changed. +func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organizationID, changedBy uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) { + if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil { + return nil, err + } + var changes []models.LoyaltySettingChange + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + if err := p.repo.LockScope(ctx, organizationID); err != nil { + return err + } + raw, err := p.repo.GetOrganizationValues(ctx, organizationID) + if err != nil { + return err + } + var current models.OrganizationLoyaltySettings + loadLoyaltyFields(organizationLoyaltyFields(¤t), raw, "organization "+organizationID.String()) + + changes, err = p.apply(ctx, organizationID, nil, changedBy, raw, + organizationLoyaltyFields(¤t), organizationLoyaltyFields(&settings), + func(key string, value *string) error { + return p.repo.SetOrganizationValue(ctx, organizationID, key, value) + }) + return err + }) + return changes, err +} + +// apply stores every field whose effective value changes and records it. A key left +// on its default and not changed is not written, so the history only shows real +// changes. current and next list the same keys in the same order. +func (p *LoyaltySettingsProcessor) apply(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, changedBy uuid.UUID, raw map[string]string, current, next []loyaltyField, store func(key string, value *string) error) ([]models.LoyaltySettingChange, error) { + var rows []entities.LoyaltySettingChange + for i, field := range next { + newValue := field.format() + if equalOptionalStrings(current[i].format(), newValue) { + continue + } + var oldValue *string + if v, ok := raw[field.key]; ok { + oldValue = &v + } + if err := store(field.key, newValue); err != nil { + return nil, err + } + rows = append(rows, entities.LoyaltySettingChange{ + OrganizationID: organizationID, + OutletID: outletID, + Key: field.key, + OldValue: oldValue, + NewValue: newValue, + ChangedBy: changedBy, + }) + } + if err := p.repo.InsertChanges(ctx, rows); err != nil { + return nil, err + } + changes := make([]models.LoyaltySettingChange, 0, len(rows)) + for _, row := range rows { + changes = append(changes, loyaltySettingChangeModel(row)) + } + return changes, nil +} + +// ListChanges returns a page of an organization's settings history, newest first. +// A nil outlet lists the whole organization, outlets included. +func (p *LoyaltySettingsProcessor) ListChanges(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int) (*models.PaginatedResponse[models.LoyaltySettingChange], error) { + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 20 + } + rows, total, err := p.repo.ListChanges(ctx, repository.LoyaltySettingChangeFilter{ + OrganizationID: organizationID, OutletID: outletID, Offset: (page - 1) * limit, Limit: limit, + }) + if err != nil { + return nil, err + } + items := make([]models.LoyaltySettingChange, 0, len(rows)) + for _, row := range rows { + items = append(items, loyaltySettingChangeModel(row)) + } + return &models.PaginatedResponse[models.LoyaltySettingChange]{ + Data: items, + Pagination: models.Pagination{ + Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit)), + }, + }, nil +} + +func loyaltySettingChangeModel(row entities.LoyaltySettingChange) models.LoyaltySettingChange { + return models.LoyaltySettingChange{ + ID: row.ID, + OrganizationID: row.OrganizationID, + OutletID: row.OutletID, + Key: row.Key, + OldValue: row.OldValue, + NewValue: row.NewValue, + ChangedBy: row.ChangedBy, + CreatedAt: row.CreatedAt, + } +} + +// outletLoyaltyFields describes every outlet key (F1), bound to s. +func outletLoyaltyFields(s *models.OutletLoyaltySettings) []loyaltyField { + return []loyaltyField{ + boolLoyaltyField(constants.LoyaltyPointEnabledKey, &s.Point.Enabled, false), + intLoyaltyField(constants.LoyaltyPointEarnPerAmountKey, &s.Point.EarnPerAmount, constants.LoyaltyPointEarnPerAmountDefault, 1, noLoyaltyMax), + intLoyaltyField(constants.LoyaltyPointEarnValueKey, &s.Point.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax), + intLoyaltyField(constants.LoyaltyPointMinOrderAmountKey, &s.Point.MinOrderAmount, 0, 0, noLoyaltyMax), + optionalIntLoyaltyField(constants.LoyaltyPointMaxPerOrderKey, &s.Point.MaxPerOrder, 0), + + boolLoyaltyField(constants.LoyaltyCoinEnabledKey, &s.Coin.Enabled, false), + intLoyaltyField(constants.LoyaltyCoinEarnPerAmountKey, &s.Coin.EarnPerAmount, constants.LoyaltyCoinEarnPerAmountDefault, 1, noLoyaltyMax), + intLoyaltyField(constants.LoyaltyCoinEarnValueKey, &s.Coin.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax), + intLoyaltyField(constants.LoyaltyCoinMinOrderAmountKey, &s.Coin.MinOrderAmount, 0, 0, noLoyaltyMax), + optionalIntLoyaltyField(constants.LoyaltyCoinMaxPerOrderKey, &s.Coin.MaxPerOrder, 0), + + boolLoyaltyField(constants.LoyaltyPointAcceptPaymentKey, &s.PointPayment.AcceptPayment, false), + intLoyaltyField(constants.LoyaltyPointMinPaymentPointsKey, &s.PointPayment.MinPaymentPoints, constants.LoyaltyMinPaymentPointsDefault, 1, noLoyaltyMax), + intLoyaltyField(constants.LoyaltyPointMaxPaymentPercentKey, &s.PointPayment.MaxPaymentPercent, constants.LoyaltyMaxPaymentPercentDefault, 0, 100), + } +} + +// organizationLoyaltyFields describes every organization key (F2, F12), bound to s. +func organizationLoyaltyFields(s *models.OrganizationLoyaltySettings) []loyaltyField { + fields := []loyaltyField{ + intLoyaltyField(constants.LoyaltyPointValueKey, &s.PointValue, constants.LoyaltyPointValueDefault, 1, noLoyaltyMax), + intLoyaltyField(constants.LoyaltyExchangeCoinAmountKey, &s.Exchange.CoinAmount, constants.LoyaltyExchangeAmountDefault, 1, noLoyaltyMax), + intLoyaltyField(constants.LoyaltyExchangePointAmountKey, &s.Exchange.PointAmount, constants.LoyaltyExchangeAmountDefault, 1, noLoyaltyMax), + boolLoyaltyField(constants.LoyaltyTransferEnabledKey, &s.Transfer.Enabled, constants.LoyaltyTransferEnabledDefault), + intLoyaltyField(constants.LoyaltyTransferMinAmountKey, &s.Transfer.MinAmount, constants.LoyaltyTransferMinAmountDefault, 1, noLoyaltyMax), + optionalIntLoyaltyField(constants.LoyaltyTransferMaxPerTransactionKey, &s.Transfer.MaxPerTransaction, 1), + optionalIntLoyaltyField(constants.LoyaltyTransferDailyLimitKey, &s.Transfer.DailyLimit, 1), + } + fields = append(fields, expiryLoyaltyFields("loyalty.point.", &s.PointExpiry)...) + return append(fields, expiryLoyaltyFields("loyalty.coin.", &s.CoinExpiry)...) +} + +func expiryLoyaltyFields(prefix string, s *models.LoyaltyExpirySettings) []loyaltyField { + return []loyaltyField{ + boolLoyaltyField(prefix+constants.LoyaltyExpiryEnabledSuffix, &s.Enabled, false), + intLoyaltyField(prefix+constants.LoyaltyExpiryPeriodSuffix, &s.Period, constants.LoyaltyExpiryPeriodDefault, 1, noLoyaltyMax), + enumLoyaltyField(prefix+constants.LoyaltyExpiryUnitSuffix, &s.Unit, constants.LoyaltyExpiryUnitDefault, + constants.LoyaltyExpiryUnitDay, constants.LoyaltyExpiryUnitMonth), + boolLoyaltyField(prefix+constants.LoyaltyExpiryEndOfMonthSuffix, &s.EndOfMonth, false), + intLoyaltyField(prefix+constants.LoyaltyExpiryReminderDaysSuffix, &s.ReminderDays, constants.LoyaltyExpiryReminderDaysDefault, 0, noLoyaltyMax), + } +} + +// loyaltyField is one setting key bound to the struct field that holds it. The same +// description drives reading (parse or default), validating, and writing (format). +type loyaltyField struct { + key string + // parse sets the field from a stored value, or reports it unusable. + parse func(raw string) bool + // reset sets the field to its default. + reset func() + // validate checks the field's current value. + validate func() error + // format is the field's current value as stored; nil means no stored value. + format func() *string +} + +const noLoyaltyMax = int64(-1) + +func intLoyaltyField(key string, v *int64, def, min, max int64) loyaltyField { + check := func(n int64) error { + if n < min { + return fmt.Errorf("%w: %s must be at least %d", ErrInvalidLoyaltySettings, key, min) + } + if max != noLoyaltyMax && n > max { + return fmt.Errorf("%w: %s must be at most %d", ErrInvalidLoyaltySettings, key, max) + } + return nil + } + return loyaltyField{ + key: key, + parse: func(raw string) bool { + n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil || check(n) != nil { + return false + } + *v = n + return true + }, + reset: func() { *v = def }, + validate: func() error { return check(*v) }, + format: func() *string { s := strconv.FormatInt(*v, 10); return &s }, + } +} + +// optionalIntLoyaltyField is a limit that may be unset, meaning no limit. +func optionalIntLoyaltyField(key string, v **int64, min int64) loyaltyField { + return loyaltyField{ + key: key, + parse: func(raw string) bool { + n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil || n < min { + return false + } + *v = &n + return true + }, + reset: func() { *v = nil }, + validate: func() error { + if *v != nil && **v < min { + return fmt.Errorf("%w: %s must be at least %d, or empty for no limit", ErrInvalidLoyaltySettings, key, min) + } + return nil + }, + format: func() *string { + if *v == nil { + return nil + } + s := strconv.FormatInt(**v, 10) + return &s + }, + } +} + +func boolLoyaltyField(key string, v *bool, def bool) loyaltyField { + return loyaltyField{ + key: key, + parse: func(raw string) bool { + b, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return false + } + *v = b + return true + }, + reset: func() { *v = def }, + validate: func() error { return nil }, + format: func() *string { s := strconv.FormatBool(*v); return &s }, + } +} + +func enumLoyaltyField(key string, v *string, def string, allowed ...string) loyaltyField { + valid := func(s string) bool { + for _, a := range allowed { + if s == a { + return true + } + } + return false + } + return loyaltyField{ + key: key, + parse: func(raw string) bool { + s := strings.ToUpper(strings.TrimSpace(raw)) + if !valid(s) { + return false + } + *v = s + return true + }, + reset: func() { *v = def }, + validate: func() error { + if !valid(*v) { + return fmt.Errorf("%w: %s must be one of %s", ErrInvalidLoyaltySettings, key, strings.Join(allowed, ", ")) + } + return nil + }, + format: func() *string { s := *v; return &s }, + } +} + +// loadLoyaltyFields fills each field from its stored value, or its default when there +// is none. A stored value that cannot be used also falls back to the default, so a bad +// row can never reach a calculation, and it is logged so it gets fixed. +func loadLoyaltyFields(fields []loyaltyField, raw map[string]string, scope string) { + for _, f := range fields { + f.reset() + value, ok := raw[f.key] + if !ok { + continue + } + if !f.parse(value) { + f.reset() + logger.NonContext.Warnf("Loyalty setting %s of %s has an unusable value %q; using the default", f.key, scope, value) + } + } +} + +func validateLoyaltyFields(fields []loyaltyField) error { + for _, f := range fields { + if err := f.validate(); err != nil { + return err + } + } + return nil +} + +func equalOptionalStrings(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} diff --git a/internal/processor/loyalty_settings_processor_test.go b/internal/processor/loyalty_settings_processor_test.go new file mode 100644 index 0000000..2cb1d9d --- /dev/null +++ b/internal/processor/loyalty_settings_processor_test.go @@ -0,0 +1,322 @@ +package processor + +import ( + "context" + "os" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +func TestMain(m *testing.M) { + // Some processors log; the server sets the logger up at startup. + logger.Setup("fatal", "json") + os.Exit(m.Run()) +} + +// loyaltyRepoFake keeps settings in maps. It does not check for a transaction. +type loyaltyRepoFake struct { + outletOrg map[uuid.UUID]uuid.UUID + outletValues map[string]string + orgValues map[string]string + changes []entities.LoyaltySettingChange + locks []uuid.UUID +} + +func (f *loyaltyRepoFake) GetOutletValues(context.Context, uuid.UUID) (map[string]string, error) { + return copyStringMap(f.outletValues), nil +} + +func (f *loyaltyRepoFake) GetOrganizationValues(context.Context, uuid.UUID) (map[string]string, error) { + return copyStringMap(f.orgValues), nil +} + +func (f *loyaltyRepoFake) OutletOrganizationID(_ context.Context, outletID uuid.UUID) (uuid.UUID, error) { + org, ok := f.outletOrg[outletID] + if !ok { + return uuid.Nil, repository.ErrLoyaltyOutletNotFound + } + return org, nil +} + +func (f *loyaltyRepoFake) LockScope(_ context.Context, id uuid.UUID) error { + f.locks = append(f.locks, id) + return nil +} + +func (f *loyaltyRepoFake) SetOutletValue(_ context.Context, _ uuid.UUID, key string, value *string) error { + f.outletValues = setOptional(f.outletValues, key, value) + return nil +} + +func (f *loyaltyRepoFake) SetOrganizationValue(_ context.Context, _ uuid.UUID, key string, value *string) error { + f.orgValues = setOptional(f.orgValues, key, value) + return nil +} + +func (f *loyaltyRepoFake) InsertChanges(_ context.Context, changes []entities.LoyaltySettingChange) error { + f.changes = append(f.changes, changes...) + return nil +} + +func (f *loyaltyRepoFake) ListChanges(context.Context, repository.LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) { + return f.changes, int64(len(f.changes)), nil +} + +func copyStringMap(m map[string]string) map[string]string { + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +func setOptional(m map[string]string, key string, value *string) map[string]string { + if m == nil { + m = map[string]string{} + } + if value == nil { + delete(m, key) + } else { + m[key] = *value + } + return m +} + +// txRunnerFake runs fn in place. +type txRunnerFake struct{} + +func (txRunnerFake) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error { + return fn(ctx) +} + +func TestLoyaltySettings_OutletWithoutSettingsGetsEveryDefault(t *testing.T) { + p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{}) + s, err := p.Outlet(context.Background(), uuid.New()) + require.NoError(t, err) + assert.Equal(t, models.OutletLoyaltySettings{ + Point: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 100, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil}, + Coin: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 25000, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil}, + PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100}, + }, *s) +} + +func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing.T) { + p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{}) + s, err := p.Organization(context.Background(), uuid.New()) + require.NoError(t, err) + expiry := models.LoyaltyExpirySettings{Enabled: false, Period: 12, Unit: "MONTH", EndOfMonth: false, ReminderDays: 7} + assert.Equal(t, models.OrganizationLoyaltySettings{ + PointValue: 1, + Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}, + Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1}, + PointExpiry: expiry, + CoinExpiry: expiry, + }, *s) +} + +func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) { + repo := &loyaltyRepoFake{ + outletValues: map[string]string{ + constants.LoyaltyPointEnabledKey: "true", + constants.LoyaltyPointEarnPerAmountKey: " 1000 ", + constants.LoyaltyPointMaxPerOrderKey: "500", + constants.LoyaltyPointMaxPaymentPercentKey: "50", + "loyalty.unknown": "ignored", + }, + orgValues: map[string]string{ + constants.LoyaltyPointValueKey: "100", + constants.LoyaltyTransferDailyLimitKey: "10000", + "loyalty.coin.expiry_unit": "day", + }, + } + p := NewLoyaltySettingsProcessor(repo, txRunnerFake{}) + + outlet, err := p.Outlet(context.Background(), uuid.New()) + require.NoError(t, err) + assert.True(t, outlet.Point.Enabled) + assert.Equal(t, int64(1000), outlet.Point.EarnPerAmount) + assert.Equal(t, int64(500), *outlet.Point.MaxPerOrder) + assert.Equal(t, int64(50), outlet.PointPayment.MaxPaymentPercent) + + org, err := p.Organization(context.Background(), uuid.New()) + require.NoError(t, err) + assert.Equal(t, int64(100), org.PointValue) + assert.Equal(t, int64(10000), *org.Transfer.DailyLimit) + assert.Equal(t, "DAY", org.CoinExpiry.Unit) + assert.Equal(t, "MONTH", org.PointExpiry.Unit) +} + +// A bad row must never reach a calculation: earn_per_amount 0 would divide by zero. +func TestLoyaltySettings_UnusableStoredValuesFallBackToDefault(t *testing.T) { + repo := &loyaltyRepoFake{ + outletValues: map[string]string{ + constants.LoyaltyPointEnabledKey: "yes please", + constants.LoyaltyPointEarnPerAmountKey: "0", + constants.LoyaltyCoinEarnValueKey: "-1", + constants.LoyaltyPointMaxPerOrderKey: "abc", + constants.LoyaltyPointMaxPaymentPercentKey: "150", + }, + orgValues: map[string]string{ + constants.LoyaltyPointValueKey: "0", + "loyalty.point.expiry_unit": "YEAR", + "loyalty.point.expiry_period": "", + }, + } + p := NewLoyaltySettingsProcessor(repo, txRunnerFake{}) + + outlet, err := p.Outlet(context.Background(), uuid.New()) + require.NoError(t, err) + assert.False(t, outlet.Point.Enabled) + assert.Equal(t, int64(100), outlet.Point.EarnPerAmount) + assert.Equal(t, int64(1), outlet.Coin.EarnValue) + assert.Nil(t, outlet.Point.MaxPerOrder) + assert.Equal(t, int64(100), outlet.PointPayment.MaxPaymentPercent) + + for name, raw := range map[string]string{"not set": "", "garbage": "abc", "zero": "0", "negative": "-5"} { + repo.orgValues = map[string]string{} + if raw != "" { + repo.orgValues[constants.LoyaltyPointValueKey] = raw + } + value, err := p.PointValue(context.Background(), uuid.New()) + require.NoError(t, err) + assert.Equal(t, constants.LoyaltyPointValueDefault, value, name) + } +} + +func TestLoyaltySettings_UpdateRecordsOnlyRealChanges(t *testing.T) { + org, outlet, admin := uuid.New(), uuid.New(), uuid.New() + repo := &loyaltyRepoFake{ + outletOrg: map[uuid.UUID]uuid.UUID{outlet: org}, + outletValues: map[string]string{constants.LoyaltyPointEarnPerAmountKey: "200"}, + } + p := NewLoyaltySettingsProcessor(repo, txRunnerFake{}) + ctx := context.Background() + + settings, err := p.Outlet(ctx, outlet) + require.NoError(t, err) + settings.Point.Enabled = true // default false -> true + settings.Point.EarnPerAmount = 100 // stored 200 -> 100 (the default, but set explicitly) + settings.Point.MaxPerOrder = ptr(int64(0)) // unset -> 0 + // Everything else stays on its default. + + changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings) + require.NoError(t, err) + byKey := map[string]models.LoyaltySettingChange{} + for _, c := range changes { + byKey[c.Key] = c + assert.Equal(t, admin, c.ChangedBy) + assert.Equal(t, org, c.OrganizationID) + assert.Equal(t, &outlet, c.OutletID) + } + require.Len(t, byKey, 3, "only the three keys that changed") + assert.Nil(t, byKey[constants.LoyaltyPointEnabledKey].OldValue, "was on its default") + assert.Equal(t, "true", *byKey[constants.LoyaltyPointEnabledKey].NewValue) + assert.Equal(t, "200", *byKey[constants.LoyaltyPointEarnPerAmountKey].OldValue) + assert.Equal(t, "100", *byKey[constants.LoyaltyPointEarnPerAmountKey].NewValue) + assert.Equal(t, "0", *byKey[constants.LoyaltyPointMaxPerOrderKey].NewValue) + assert.Len(t, repo.changes, 3) + assert.Equal(t, []uuid.UUID{outlet}, repo.locks) + + after, err := p.Outlet(ctx, outlet) + require.NoError(t, err) + assert.Equal(t, *settings, *after) + + // Saving the same settings again changes nothing and records nothing. + changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after) + require.NoError(t, err) + assert.Empty(t, changes) + assert.Len(t, repo.changes, 3) + + // Clearing a limit removes the stored value, recorded as a change to nothing. + after.Point.MaxPerOrder = nil + changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after) + require.NoError(t, err) + require.Len(t, changes, 1) + assert.Equal(t, "0", *changes[0].OldValue) + assert.Nil(t, changes[0].NewValue) + assert.NotContains(t, repo.outletValues, constants.LoyaltyPointMaxPerOrderKey) +} + +func TestLoyaltySettings_UpdateOrganization(t *testing.T) { + org, admin := uuid.New(), uuid.New() + repo := &loyaltyRepoFake{} + p := NewLoyaltySettingsProcessor(repo, txRunnerFake{}) + ctx := context.Background() + + settings, err := p.Organization(ctx, org) + require.NoError(t, err) + settings.PointValue = 100 + settings.CoinExpiry.Enabled = true + settings.CoinExpiry.Unit = constants.LoyaltyExpiryUnitDay + + changes, err := p.UpdateOrganization(ctx, org, admin, *settings) + require.NoError(t, err) + keys := []string{} + for _, c := range changes { + keys = append(keys, c.Key) + assert.Nil(t, c.OutletID, "organization settings have no outlet") + } + assert.ElementsMatch(t, []string{constants.LoyaltyPointValueKey, "loyalty.coin.expiry_enabled", "loyalty.coin.expiry_unit"}, keys) + + value, err := p.PointValue(ctx, org) + require.NoError(t, err) + assert.Equal(t, int64(100), value) +} + +func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) { + org, outlet := uuid.New(), uuid.New() + repo := &loyaltyRepoFake{outletOrg: map[uuid.UUID]uuid.UUID{outlet: org}} + p := NewLoyaltySettingsProcessor(repo, txRunnerFake{}) + ctx := context.Background() + + for name, mutate := range map[string]func(*models.OutletLoyaltySettings){ + "earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 }, + "negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 }, + "negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 }, + "negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) }, + "payment percent over 100": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = 101 }, + "negative payment percent": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = -1 }, + "min payment points 0": func(s *models.OutletLoyaltySettings) { s.PointPayment.MinPaymentPoints = 0 }, + } { + s, err := p.Outlet(ctx, outlet) + require.NoError(t, err) + mutate(s) + _, err = p.UpdateOutlet(ctx, org, outlet, uuid.New(), *s) + assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name) + } + + for name, mutate := range map[string]func(*models.OrganizationLoyaltySettings){ + "point value 0": func(s *models.OrganizationLoyaltySettings) { s.PointValue = 0 }, + "exchange coin amount 0": func(s *models.OrganizationLoyaltySettings) { s.Exchange.CoinAmount = 0 }, + "transfer min 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.MinAmount = 0 }, + "daily limit 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.DailyLimit = ptr(int64(0)) }, + "expiry period 0": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Period = 0 }, + "expiry unit YEAR": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.Unit = "YEAR" }, + "negative reminder days": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.ReminderDays = -1 }, + } { + s, err := p.Organization(ctx, org) + require.NoError(t, err) + mutate(s) + _, err = p.UpdateOrganization(ctx, org, uuid.New(), *s) + assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name) + } + assert.Empty(t, repo.changes, "nothing is written when a value is invalid") + assert.Empty(t, repo.locks, "invalid input is rejected before touching the database") + + // An outlet of another organization. + s, err := p.Outlet(ctx, outlet) + require.NoError(t, err) + _, err = p.UpdateOutlet(ctx, uuid.New(), outlet, uuid.New(), *s) + assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound) + _, err = p.UpdateOutlet(ctx, org, uuid.New(), uuid.New(), *s) + assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound) +} diff --git a/internal/processor/wallet_query_processor.go b/internal/processor/wallet_query_processor.go index e1051dc..02ae40d 100644 --- a/internal/processor/wallet_query_processor.go +++ b/internal/processor/wallet_query_processor.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strconv" "strings" "time" @@ -41,11 +40,17 @@ var walletDisplayLocation = func() *time.Location { // history and upcoming expiry (docs/prd-point-coin.md F6). It only reads. type WalletQueryProcessor struct { repo repository.WalletQueryRepository - settings repository.OrganizationSettingRepository + settings pointValueReader now func() time.Time } -func NewWalletQueryProcessor(repo repository.WalletQueryRepository, settings repository.OrganizationSettingRepository) *WalletQueryProcessor { +// pointValueReader gives the rupiah value of one EnakPoint in an organization. +// LoyaltySettingsProcessor is one. +type pointValueReader interface { + PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) +} + +func NewWalletQueryProcessor(repo repository.WalletQueryRepository, settings pointValueReader) *WalletQueryProcessor { return &WalletQueryProcessor{repo: repo, settings: settings, now: time.Now} } @@ -61,7 +66,7 @@ func (p *WalletQueryProcessor) Summary(ctx context.Context, customerID uuid.UUID if err != nil { return nil, err } - pointValue, err := p.pointValue(ctx, organizationID) + pointValue, err := p.settings.PointValue(ctx, organizationID) if err != nil { return nil, err } @@ -192,23 +197,6 @@ func (p *WalletQueryProcessor) recent(ctx context.Context, customerID uuid.UUID, return p.toTransactions(ctx, rows) } -// pointValue reads the organization's rupiah value of one EnakPoint. A missing or -// unusable value falls back to the default rather than failing the whole screen. -func (p *WalletQueryProcessor) pointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) { - raw, ok, err := p.settings.Get(ctx, organizationID, constants.LoyaltyPointValueKey) - if err != nil { - return 0, err - } - if !ok { - return constants.LoyaltyPointValueDefault, nil - } - value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) - if err != nil || value < 1 { - return constants.LoyaltyPointValueDefault, nil - } - return value, nil -} - func (p *WalletQueryProcessor) toTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) { return buildWalletTransactions(ctx, p.repo, rows) } diff --git a/internal/processor/wallet_query_processor_test.go b/internal/processor/wallet_query_processor_test.go index 573f3df..1a4a9aa 100644 --- a/internal/processor/wallet_query_processor_test.go +++ b/internal/processor/wallet_query_processor_test.go @@ -68,13 +68,8 @@ func (f *walletQueryRepoFake) ListLotsBySourceTransactions(_ context.Context, id type orgSettingsFake map[string]string -func (f orgSettingsFake) Get(_ context.Context, _ uuid.UUID, key string) (string, bool, error) { - v, ok := f[key] - return v, ok, nil -} - func newWalletQueryTest(repo *walletQueryRepoFake, settings orgSettingsFake) *WalletQueryProcessor { - p := NewWalletQueryProcessor(repo, settings) + p := NewWalletQueryProcessor(repo, NewLoyaltySettingsProcessor(&loyaltyRepoFake{orgValues: settings}, txRunnerFake{})) p.now = func() time.Time { return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) } return p } diff --git a/internal/repository/loyalty_settings_repository.go b/internal/repository/loyalty_settings_repository.go new file mode 100644 index 0000000..4a0f0e0 --- /dev/null +++ b/internal/repository/loyalty_settings_repository.go @@ -0,0 +1,176 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" + + "apskel-pos-be/internal/entities" +) + +// ErrLoyaltyOutletNotFound means the outlet does not exist or belongs to another +// organization. +var ErrLoyaltyOutletNotFound = errors.New("loyalty settings: outlet not found") + +// LoyaltySettingChangeFilter narrows the loyalty settings history. +type LoyaltySettingChangeFilter struct { + OrganizationID uuid.UUID + // Nil for the whole organization, outlets included. + OutletID *uuid.UUID + Offset int + Limit int +} + +// LoyaltySettingsRepository stores the raw loyalty settings (the loyalty.* keys of +// outlet_settings and organization_settings) and their history +// (docs/prd-point-coin.md F1, F2). The loyalty settings processor turns them into +// typed values and is the only writer. +type LoyaltySettingsRepository interface { + // GetOutletValues returns the stored loyalty keys of an outlet. A key with no row, + // or a NULL value, is absent. + GetOutletValues(ctx context.Context, outletID uuid.UUID) (map[string]string, error) + GetOrganizationValues(ctx context.Context, organizationID uuid.UUID) (map[string]string, error) + // OutletOrganizationID returns ErrLoyaltyOutletNotFound for an unknown outlet. + OutletOrganizationID(ctx context.Context, outletID uuid.UUID) (uuid.UUID, error) + + // LockScope serializes writers of one outlet's or one organization's settings + // until the transaction ends, so two saves at once cannot both compute their + // changes from the same old values. + LockScope(ctx context.Context, scopeID uuid.UUID) error + // SetOutletValue stores a value, or removes the key when value is nil so it falls + // back to its default. + SetOutletValue(ctx context.Context, outletID uuid.UUID, key string, value *string) error + SetOrganizationValue(ctx context.Context, organizationID uuid.UUID, key string, value *string) error + InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error + // ListChanges returns a page of history, newest first, and the total count. + ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) +} + +type loyaltySettingsRepository struct { + db *gorm.DB +} + +func NewLoyaltySettingsRepository(db *gorm.DB) LoyaltySettingsRepository { + return &loyaltySettingsRepository{db: db} +} + +// requireTx returns the caller's transaction. Writes must run in one, together with +// the history rows they produce. +func (r *loyaltySettingsRepository) requireTx(ctx context.Context) (*gorm.DB, error) { + if tx, ok := ctx.Value(txKey).(*gorm.DB); ok && tx != nil { + return tx.WithContext(ctx), nil + } + return nil, errors.New("loyalty settings: write must run inside a transaction") +} + +func (r *loyaltySettingsRepository) values(ctx context.Context, table, scopeColumn string, scopeID uuid.UUID) (map[string]string, error) { + var rows []struct { + Key string + Value string + } + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table(table). + Select("key, value"). + Where(scopeColumn+" = ? AND key LIKE 'loyalty.%' AND value IS NOT NULL", scopeID). + Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", table, err) + } + values := make(map[string]string, len(rows)) + for _, row := range rows { + values[row.Key] = row.Value + } + return values, nil +} + +func (r *loyaltySettingsRepository) GetOutletValues(ctx context.Context, outletID uuid.UUID) (map[string]string, error) { + return r.values(ctx, "outlet_settings", "outlet_id", outletID) +} + +func (r *loyaltySettingsRepository) GetOrganizationValues(ctx context.Context, organizationID uuid.UUID) (map[string]string, error) { + return r.values(ctx, "organization_settings", "organization_id", organizationID) +} + +func (r *loyaltySettingsRepository) OutletOrganizationID(ctx context.Context, outletID uuid.UUID) (uuid.UUID, error) { + var ids []string + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table("outlets"). + Where("id = ?", outletID). + Limit(1). + Pluck("organization_id::text", &ids).Error + if err != nil { + return uuid.Nil, fmt.Errorf("failed to get outlet organization: %w", err) + } + if len(ids) == 0 { + return uuid.Nil, ErrLoyaltyOutletNotFound + } + return uuid.Parse(ids[0]) +} + +func (r *loyaltySettingsRepository) LockScope(ctx context.Context, scopeID uuid.UUID) error { + db, err := r.requireTx(ctx) + if err != nil { + return err + } + return db.Exec(`SELECT pg_advisory_xact_lock(hashtextextended(?, 0))`, "loyalty-settings:"+scopeID.String()).Error +} + +func (r *loyaltySettingsRepository) SetOutletValue(ctx context.Context, outletID uuid.UUID, key string, value *string) error { + return r.setValue(ctx, "outlet_settings", "outlet_id", outletID, key, value) +} + +func (r *loyaltySettingsRepository) SetOrganizationValue(ctx context.Context, organizationID uuid.UUID, key string, value *string) error { + return r.setValue(ctx, "organization_settings", "organization_id", organizationID, key, value) +} + +// setValue upserts or deletes one key. table and scopeColumn are never user input. +func (r *loyaltySettingsRepository) setValue(ctx context.Context, table, scopeColumn string, scopeID uuid.UUID, key string, value *string) error { + db, err := r.requireTx(ctx) + if err != nil { + return err + } + if value == nil { + err = db.Exec(`DELETE FROM `+table+` WHERE `+scopeColumn+` = ? AND key = ?`, scopeID, key).Error + } else { + err = db.Exec(`INSERT INTO `+table+` (`+scopeColumn+`, key, value) VALUES (?, ?, ?) + ON CONFLICT (`+scopeColumn+`, key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + scopeID, key, *value).Error + } + if err != nil { + return fmt.Errorf("failed to store %s in %s: %w", key, table, err) + } + return nil +} + +func (r *loyaltySettingsRepository) InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error { + if len(changes) == 0 { + return nil + } + db, err := r.requireTx(ctx) + if err != nil { + return err + } + return db.Create(&changes).Error +} + +func (r *loyaltySettingsRepository) ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) { + query := DBFromContext(ctx, r.db).WithContext(ctx). + Model(&entities.LoyaltySettingChange{}). + Where("organization_id = ?", filter.OrganizationID) + if filter.OutletID != nil { + query = query.Where("outlet_id = ?", *filter.OutletID) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count loyalty setting changes: %w", err) + } + var changes []entities.LoyaltySettingChange + err := query.Order("created_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&changes).Error + if err != nil { + return nil, 0, fmt.Errorf("failed to list loyalty setting changes: %w", err) + } + return changes, total, nil +} diff --git a/internal/repository/loyalty_settings_repository_test.go b/internal/repository/loyalty_settings_repository_test.go new file mode 100644 index 0000000..f8fb7d6 --- /dev/null +++ b/internal/repository/loyalty_settings_repository_test.go @@ -0,0 +1,149 @@ +package repository_test + +import ( + "context" + "os" + "testing" + "time" + + "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" + applogger "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" +) + +// Reads and writes loyalty settings through the processor against Postgres. Needs +// TEST_DATABASE_URL pointing at a migrated database; see wallet_repository_test.go. +func TestLoyaltySettings_AgainstPostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + applogger.Setup("fatal", "json") + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + ctx := context.Background() + + org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New() + 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 (?, 'loyalty test', 'basic'), (?, 'other', 'basic')`, org, otherOrg) + exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org) + // A setting that is not a loyalty setting must be left alone. + exec(`INSERT INTO outlet_settings (outlet_id, key, value) VALUES (?, 'receipt.footer', 'Terima kasih')`, outlet) + t.Cleanup(func() { + db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg}) + db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet) + db.Exec(`DELETE FROM organization_settings WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg}) + db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet) + db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg}) + }) + + repo := repository.NewLoyaltySettingsRepository(db) + p := processor.NewLoyaltySettingsProcessor(repo, repository.NewTxManager(db)) + + // No settings: every PRD default. + settings, err := p.Outlet(ctx, outlet) + require.NoError(t, err) + assert.False(t, settings.Point.Enabled) + assert.Equal(t, int64(100), settings.Point.EarnPerAmount) + assert.Equal(t, int64(25000), settings.Coin.EarnPerAmount) + assert.Nil(t, settings.Point.MaxPerOrder) + assert.Equal(t, int64(100), settings.PointPayment.MaxPaymentPercent) + + // Change three keys. + settings.Point.Enabled = true + settings.Point.EarnPerAmount = 1000 + settings.Coin.MaxPerOrder = ptrInt64(5) + changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings) + require.NoError(t, err) + assert.Len(t, changes, 3) + + var stored []struct{ Key, Value string } + require.NoError(t, db.Raw(`SELECT key, value FROM outlet_settings WHERE outlet_id = ? ORDER BY key`, outlet).Scan(&stored).Error) + assert.Equal(t, []struct{ Key, Value string }{ + {constants.LoyaltyCoinMaxPerOrderKey, "5"}, + {constants.LoyaltyPointEarnPerAmountKey, "1000"}, + {constants.LoyaltyPointEnabledKey, "true"}, + {"receipt.footer", "Terima kasih"}, + }, stored, "only the changed keys are stored, next to the untouched non-loyalty key") + + history, err := p.ListChanges(ctx, org, &outlet, 1, 20) + require.NoError(t, err) + require.Len(t, history.Data, 3) + for _, c := range history.Data { + assert.Equal(t, admin, c.ChangedBy) + assert.Nil(t, c.OldValue, "%s was on its default", c.Key) + assert.NotNil(t, c.NewValue) + } + + // A second change records the old value. + settings.Point.EarnPerAmount = 500 + changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *settings) + require.NoError(t, err) + require.Len(t, changes, 1) + assert.Equal(t, "1000", *changes[0].OldValue) + assert.Equal(t, "500", *changes[0].NewValue) + + // Another organization cannot change this outlet. + _, err = p.UpdateOutlet(ctx, otherOrg, outlet, admin, *settings) + assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound) + + // Writing outside a transaction is refused. + assert.Error(t, repo.SetOutletValue(ctx, outlet, constants.LoyaltyPointEnabledKey, nil)) + + // A save must wait while another save of the same organization holds the lock, so + // two admins saving at once cannot both compute their change from the same old + // value. Hold the lock in another transaction and check the save blocks until it + // is released. + txm := repository.NewTxManager(db) + holding, release, holderDone := make(chan struct{}), make(chan struct{}), make(chan error, 1) + go func() { + holderDone <- txm.WithTransaction(ctx, func(ctx context.Context) error { + if err := repo.LockScope(ctx, org); err != nil { + return err + } + close(holding) + <-release + return nil + }) + }() + <-holding + + orgSettings, err := p.Organization(ctx, org) + require.NoError(t, err) + orgSettings.PointValue = 200 + saved := make(chan error, 1) + go func() { + _, err := p.UpdateOrganization(ctx, org, admin, *orgSettings) + saved <- err + }() + select { + case err := <-saved: + t.Fatalf("the save finished while another transaction held the lock (err: %v)", err) + case <-time.After(300 * time.Millisecond): + } + close(release) + require.NoError(t, <-holderDone) + require.NoError(t, <-saved) + + value, err := p.PointValue(ctx, org) + require.NoError(t, err) + assert.Equal(t, int64(200), value) + + // The whole organization's history includes the outlet's changes. + all, err := p.ListChanges(ctx, org, nil, 1, 100) + require.NoError(t, err) + assert.EqualValues(t, 5, all.Pagination.Total, "four outlet changes and one organization change") +} + +func ptrInt64(v int64) *int64 { return &v } diff --git a/internal/repository/organization_setting_repository.go b/internal/repository/organization_setting_repository.go deleted file mode 100644 index 7c47697..0000000 --- a/internal/repository/organization_setting_repository.go +++ /dev/null @@ -1,41 +0,0 @@ -package repository - -import ( - "context" - "fmt" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -// OrganizationSettingRepository reads organization_settings, the key-value settings -// that apply to every outlet of an organization (docs/prd-point-coin.md F2). -type OrganizationSettingRepository interface { - // Get returns the stored value of a key and whether one is stored. A key that has - // never been set is not an error: the caller falls back to the key's default. - Get(ctx context.Context, organizationID uuid.UUID, key string) (string, bool, error) -} - -type organizationSettingRepository struct { - db *gorm.DB -} - -func NewOrganizationSettingRepository(db *gorm.DB) OrganizationSettingRepository { - return &organizationSettingRepository{db: db} -} - -func (r *organizationSettingRepository) Get(ctx context.Context, organizationID uuid.UUID, key string) (string, bool, error) { - var values []*string - err := DBFromContext(ctx, r.db).WithContext(ctx). - Table("organization_settings"). - Where("organization_id = ? AND key = ?", organizationID, key). - Limit(1). - Pluck("value", &values).Error - if err != nil { - return "", false, fmt.Errorf("failed to get organization setting %s: %w", key, err) - } - if len(values) == 0 || values[0] == nil { - return "", false, nil - } - return *values[0], true, nil -} -- 2.54.0 From 2bd53ee4a4c19b92a40e82f42762197de47e25cd Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 10:28:40 +0700 Subject: [PATCH 21/47] feat(loyalty): outlet loyalty settings API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET and PUT /outlets/:id/loyalty-settings (docs/prd-point-coin.md F1, PC-201) on top of the typed settings processor. The response shows every setting with its default when unset, the organization's point value, and the effective EnakPoint cashback (earn_value × point_value / earn_per_amount), so an owner cannot misread the scale. PUT applies the body on top of the current settings: fields left out keep their value, null clears an optional limit, and unknown fields are refused so a typo cannot be ignored silently. The read-only fields of the GET response are accepted and ignored, so a client can send back what it received. It returns the keys that changed. Values outside the F1 bounds answer 400, and an outlet of another organization 404. RequireAdminOrManager also lets the purchasing role through, so loyalty settings and the manual wallet adjustment from PC-107 now use a stricter RequireLoyaltyManager (superadmin, admin, manager, owner). Adds a test that registers every route, since gin panics at startup when two routes name the same path parameter differently. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 3 + internal/constants/error.go | 1 + internal/handler/loyalty_settings_db_test.go | 169 ++++++++++++++++++ internal/handler/loyalty_settings_handler.go | 72 ++++++++ internal/middleware/auth_middleware.go | 8 + internal/models/loyalty.go | 24 +++ .../processor/loyalty_settings_processor.go | 13 ++ internal/router/router.go | 9 +- internal/router/router_test.go | 38 ++++ internal/service/loyalty_settings_service.go | 117 ++++++++++++ 10 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 internal/handler/loyalty_settings_db_test.go create mode 100644 internal/handler/loyalty_settings_handler.go create mode 100644 internal/router/router_test.go create mode 100644 internal/service/loyalty_settings_service.go diff --git a/internal/app/app.go b/internal/app/app.go index 389a947..ea9cf0c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -152,6 +152,7 @@ func (a *App) Initialize(cfg *config.Config) error { validators.cashAdvanceValidator, services.walletAdminService, validators.walletValidator, + services.loyaltySettingsService, a.redisClient, ) @@ -474,6 +475,7 @@ type services struct { expenseService *service.ExpenseServiceImpl cashAdvanceService *service.CashAdvanceServiceImpl walletAdminService *service.WalletAdminServiceImpl + loyaltySettingsService *service.LoyaltySettingsServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -556,6 +558,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con expenseService: service.NewExpenseService(processors.expenseProcessor), cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor), walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor), + loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor), } } diff --git a/internal/constants/error.go b/internal/constants/error.go index 6f09efd..e2c13a8 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -64,6 +64,7 @@ const ( ExpenseServiceEntity = "expense_service" CashAdvanceServiceEntity = "cash_advance_service" WalletServiceEntity = "wallet_service" + LoyaltySettingsServiceEntity = "loyalty_settings_service" ) var HttpErrorMap = map[string]int{ diff --git a/internal/handler/loyalty_settings_db_test.go b/internal/handler/loyalty_settings_db_test.go new file mode 100644 index 0000000..72bf8d9 --- /dev/null +++ b/internal/handler/loyalty_settings_db_test.go @@ -0,0 +1,169 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + "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/appcontext" + applogger "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/middleware" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" + "apskel-pos-be/internal/service" +) + +// Drives GET/PUT /outlets/:id/loyalty-settings over HTTP down to Postgres. Needs +// TEST_DATABASE_URL pointing at a migrated database; see +// internal/repository/wallet_repository_test.go. +func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + applogger.Setup("fatal", "json") + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + + org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New() + 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 (?, 'A', 'basic'), (?, 'B', 'basic')`, org, otherOrg) + exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org) + t.Cleanup(func() { + db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg}) + db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet) + db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet) + db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg}) + }) + + settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db)) + h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings)) + auth := middleware.NewAuthMiddleware(nil) + + gin.SetMode(gin.TestMode) + router := gin.New() + as := func(orgID uuid.UUID, role string) gin.HandlerFunc { + return func(c *gin.Context) { + ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, orgID.String()) + ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String()) + ctx = context.WithValue(ctx, appcontext.UserRoleKey, role) + c.Request = c.Request.WithContext(ctx) + } + } + for prefix, who := range map[string]struct { + org uuid.UUID + role string + }{"/manager": {org, "manager"}, "/purchasing": {org, "purchasing"}, "/other": {otherOrg, "admin"}} { + g := router.Group(prefix, as(who.org, who.role)) + g.GET("/outlets/:outlet_id/loyalty-settings", h.GetOutletSettings) + g.PUT("/outlets/:outlet_id/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOutletSettings) + } + call := func(method, path string, body string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + var out map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String()) + return rec.Code, out + } + path := "/outlets/" + outlet.String() + "/loyalty-settings" + data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) } + + // An outlet without settings shows every default and 1% cashback. + status, body := call(http.MethodGet, "/manager"+path, "") + require.Equal(t, http.StatusOK, status, body) + got := data(body) + assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(100), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"]) + assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(25000), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"]) + assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"]) + assert.EqualValues(t, 1, got["point_value"]) + assert.EqualValues(t, 1, got["point_cashback_percent"]) + + // A partial update keeps everything it does not mention. + status, body = call(http.MethodPut, "/manager"+path, `{"point": {"enabled": true, "earn_per_amount": 1000}, "coin": {"max_per_order": 3}}`) + require.Equal(t, http.StatusOK, status, body) + got = data(body) + assert.Equal(t, true, got["point"].(map[string]any)["enabled"]) + assert.EqualValues(t, 1000, got["point"].(map[string]any)["earn_per_amount"]) + assert.EqualValues(t, 1, got["point"].(map[string]any)["earn_value"], "untouched") + assert.EqualValues(t, 25000, got["coin"].(map[string]any)["earn_per_amount"], "untouched") + assert.EqualValues(t, 3, got["coin"].(map[string]any)["max_per_order"]) + assert.EqualValues(t, 0.1, got["point_cashback_percent"], "1 point per Rp 1.000 at Rp 1 a point") + assert.Len(t, got["changes"], 3) + + // The cashback follows the organization's point value. + orgSettings, err := settings.Organization(context.Background(), org) + require.NoError(t, err) + orgSettings.PointValue = 5 + _, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings) + require.NoError(t, err) + status, body = call(http.MethodGet, "/manager"+path, "") + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 0.5, data(body)["point_cashback_percent"]) + + // Sending back what GET returned changes nothing. + echo, err := json.Marshal(data(body)) + require.NoError(t, err) + status, body = call(http.MethodPut, "/manager"+path, string(echo)) + require.Equal(t, http.StatusOK, status, body) + assert.Empty(t, data(body)["changes"]) + + // null clears a limit. + status, body = call(http.MethodPut, "/manager"+path, `{"coin": {"max_per_order": null}}`) + require.Equal(t, http.StatusOK, status, body) + assert.Nil(t, data(body)["coin"].(map[string]any)["max_per_order"]) + + // Values out of bounds, unknown fields and bad JSON are refused and change nothing. + for name, bad := range map[string]string{ + "earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`, + "negative earn_value": `{"coin": {"earn_value": -1}}`, + "negative min_order": `{"point": {"min_order_amount": -5}}`, + "negative max_per_order": `{"point": {"max_per_order": -1}}`, + "payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`, + "unknown field": `{"point": {"earn_per_amout": 50}}`, + "wrong type": `{"point": {"enabled": "yes"}}`, + "not json": `enabled=true`, + } { + status, _ = call(http.MethodPut, "/manager"+path, bad) + assert.Equal(t, http.StatusBadRequest, status, name) + } + status, body = call(http.MethodGet, "/manager"+path, "") + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 1000, data(body)["point"].(map[string]any)["earn_per_amount"]) + + // Purchasing staff can read but not change; another organization sees nothing. + status, _ = call(http.MethodPut, "/purchasing"+path, `{"point": {"enabled": false}}`) + assert.Equal(t, http.StatusForbidden, status) + status, _ = call(http.MethodGet, "/other"+path, "") + assert.Equal(t, http.StatusNotFound, status) + status, _ = call(http.MethodPut, "/other"+path, `{"point": {"enabled": false}}`) + assert.Equal(t, http.StatusNotFound, status) + status, _ = call(http.MethodGet, "/manager/outlets/not-a-uuid/loyalty-settings", "") + assert.Equal(t, http.StatusBadRequest, status) + + // Every change is in the history with who made it. + history, err := settings.ListChanges(context.Background(), org, &outlet, 1, 100) + require.NoError(t, err) + assert.EqualValues(t, 4, history.Pagination.Total, "three keys, then the limit cleared") + for _, c := range history.Data { + assert.Equal(t, admin, c.ChangedBy) + } +} diff --git a/internal/handler/loyalty_settings_handler.go b/internal/handler/loyalty_settings_handler.go new file mode 100644 index 0000000..dd91544 --- /dev/null +++ b/internal/handler/loyalty_settings_handler.go @@ -0,0 +1,72 @@ +package handler + +import ( + "io" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" +) + +// loyaltySettingsBodyLimit caps a settings body; a real one is well under 2 KB. +const loyaltySettingsBodyLimit = 64 << 10 + +// LoyaltySettingsHandler serves the loyalty settings (docs/prd-point-coin.md F1, F2). +type LoyaltySettingsHandler struct { + loyaltySettingsService service.LoyaltySettingsService +} + +func NewLoyaltySettingsHandler(loyaltySettingsService service.LoyaltySettingsService) *LoyaltySettingsHandler { + return &LoyaltySettingsHandler{loyaltySettingsService: loyaltySettingsService} +} + +func (h *LoyaltySettingsHandler) GetOutletSettings(c *gin.Context) { + ctx := c.Request.Context() + outletID, ok := parseUUIDParam(c, "outlet_id", "LoyaltySettingsHandler::GetOutletSettings") + if !ok { + return + } + response := h.loyaltySettingsService.GetOutletSettings(ctx, appcontext.FromGinContext(ctx), outletID) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::GetOutletSettings -> service call failed") + } + util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::GetOutletSettings") +} + +func (h *LoyaltySettingsHandler) UpdateOutletSettings(c *gin.Context) { + ctx := c.Request.Context() + outletID, ok := parseUUIDParam(c, "outlet_id", "LoyaltySettingsHandler::UpdateOutletSettings") + if !ok { + return + } + body, err := io.ReadAll(io.LimitReader(c.Request.Body, loyaltySettingsBodyLimit)) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "unable to read request body"), + }), "LoyaltySettingsHandler::UpdateOutletSettings") + return + } + response := h.loyaltySettingsService.UpdateOutletSettings(ctx, appcontext.FromGinContext(ctx), outletID, body) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::UpdateOutletSettings -> service call failed") + } + util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::UpdateOutletSettings") +} + +// parseUUIDParam reads a UUID path parameter, answering 400 itself when it is not one. +func parseUUIDParam(c *gin.Context, name, method string) (uuid.UUID, bool) { + id, err := uuid.Parse(c.Param(name)) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid "+name), + }), method) + return uuid.Nil, false + } + return id, true +} diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go index ad42527..9bfc7a8 100644 --- a/internal/middleware/auth_middleware.go +++ b/internal/middleware/auth_middleware.go @@ -85,6 +85,14 @@ func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc { return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing") } +// RequireLoyaltyManager guards what moves or prices EnakPoint and EnakCoin: loyalty +// settings and manual wallet adjustments (docs/prd-point-coin.md F1, F2, F7). Only +// admins and managers may do these; unlike RequireAdminOrManager it does not let the +// purchasing role through. +func (m *AuthMiddleware) RequireLoyaltyManager() gin.HandlerFunc { + return m.RequireRole("superadmin", "admin", "manager", "owner") +} + func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc { return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing") } diff --git a/internal/models/loyalty.go b/internal/models/loyalty.go index 69d0970..09aaed6 100644 --- a/internal/models/loyalty.go +++ b/internal/models/loyalty.go @@ -1,6 +1,7 @@ package models import ( + "math" "time" "github.com/google/uuid" @@ -79,3 +80,26 @@ type LoyaltySettingChange struct { ChangedBy uuid.UUID `json:"changed_by"` CreatedAt time.Time `json:"created_at"` } + +// OutletLoyaltySettingsView is GET and PUT /outlets/:id/loyalty-settings. +type OutletLoyaltySettingsView struct { + OutletID uuid.UUID `json:"outlet_id"` + OutletLoyaltySettings + // The organization's rupiah value of one EnakPoint, which the cashback depends on. + PointValue int64 `json:"point_value"` + // Effective EnakPoint cashback in percent: earn_value × point_value / + // earn_per_amount × 100. Shown next to the setting so an owner cannot misread the + // scale (F1). + PointCashbackPercent float64 `json:"point_cashback_percent"` + // Set on PUT: the keys that changed. + Changes []LoyaltySettingChange `json:"changes,omitempty"` +} + +// LoyaltyCashbackPercent is earnValue × pointValue / earnPerAmount as a percentage, +// rounded to two decimals. +func LoyaltyCashbackPercent(earnValue, pointValue, earnPerAmount int64) float64 { + if earnPerAmount <= 0 { + return 0 + } + return math.Round(float64(earnValue)*float64(pointValue)*10000/float64(earnPerAmount)) / 100 +} diff --git a/internal/processor/loyalty_settings_processor.go b/internal/processor/loyalty_settings_processor.go index 330456f..58c6aa7 100644 --- a/internal/processor/loyalty_settings_processor.go +++ b/internal/processor/loyalty_settings_processor.go @@ -44,6 +44,19 @@ func (p *LoyaltySettingsProcessor) Outlet(ctx context.Context, outletID uuid.UUI return &s, nil } +// OutletForOrganization returns an outlet's settings for the dashboard, and +// repository.ErrLoyaltyOutletNotFound unless the outlet belongs to the organization. +func (p *LoyaltySettingsProcessor) OutletForOrganization(ctx context.Context, organizationID, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) { + owner, err := p.repo.OutletOrganizationID(ctx, outletID) + if err != nil { + return nil, err + } + if owner != organizationID { + return nil, repository.ErrLoyaltyOutletNotFound + } + return p.Outlet(ctx, outletID) +} + // Organization returns an organization's settings. func (p *LoyaltySettingsProcessor) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) { raw, err := p.repo.GetOrganizationValues(ctx, organizationID) diff --git a/internal/router/router.go b/internal/router/router.go index ad4fbcb..857e0fb 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -55,12 +55,13 @@ type Router struct { expenseHandler *handler.ExpenseHandler cashAdvanceHandler *handler.CashAdvanceHandler walletAdminHandler *handler.WalletAdminHandler + loyaltySettingsHandler *handler.LoyaltySettingsHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -107,6 +108,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator), cashAdvanceHandler: handler.NewCashAdvanceHandler(cashAdvanceService, cashAdvanceValidator), walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator), + loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService), redisClient: redisClient, } } @@ -621,7 +623,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { marketingCustomers := gamification.Group("/customers") { marketingCustomers.GET("/:id/wallet", r.walletAdminHandler.GetCustomerWallet) - marketingCustomers.POST("/:id/wallet/adjust", r.walletAdminHandler.AdjustCustomerWallet) + marketingCustomers.POST("/:id/wallet/adjust", r.authMiddleware.RequireLoyaltyManager(), r.walletAdminHandler.AdjustCustomerWallet) } campaignRules := gamification.Group("/campaign-rules") @@ -644,6 +646,9 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { outlets.PUT("/detail/:id", r.outletHandler.UpdateOutlet) outlets.GET("/printer-setting/:outlet_id", r.outletSettingHandler.GetPrinterSettings) outlets.PUT("/printer-setting/:outlet_id", r.outletSettingHandler.UpdatePrinterSettings) + // EnakPoint & EnakCoin settings of the outlet (docs/prd-point-coin.md F1) + outlets.GET("/:outlet_id/loyalty-settings", r.loyaltySettingsHandler.GetOutletSettings) + outlets.PUT("/:outlet_id/loyalty-settings", r.authMiddleware.RequireLoyaltyManager(), r.loyaltySettingsHandler.UpdateOutletSettings) outlets.GET("/:outlet_id/tables/available", r.tableHandler.GetAvailableTables) outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables) // Reports diff --git a/internal/router/router_test.go b/internal/router/router_test.go new file mode 100644 index 0000000..ea80625 --- /dev/null +++ b/internal/router/router_test.go @@ -0,0 +1,38 @@ +package router + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "apskel-pos-be/config" + "apskel-pos-be/internal/middleware" +) + +// Gin panics at startup when two routes disagree on a path parameter's name at the +// same position (say /outlets/:id/x next to /outlets/:outlet_id/y). Registering every +// route here catches that in a test instead of on deploy. Handlers are nil: nothing is +// served, only registered. +func TestAllRoutesRegister(t *testing.T) { + r := &Router{ + config: &config.Config{}, + authMiddleware: middleware.NewAuthMiddleware(nil), + customerAuthMiddleware: middleware.NewCustomerAuthMiddleware("test"), + } + engine := r.Init() + + registered := map[string]bool{} + for _, route := range engine.Routes() { + registered[route.Method+" "+route.Path] = true + } + for _, want := range []string{ + "GET /api/v1/customer/wallet", + "GET /api/v1/customer/wallet/transactions", + "GET /api/v1/marketing/customers/:id/wallet", + "POST /api/v1/marketing/customers/:id/wallet/adjust", + "GET /api/v1/outlets/:outlet_id/loyalty-settings", + "PUT /api/v1/outlets/:outlet_id/loyalty-settings", + } { + assert.True(t, registered[want], want) + } +} diff --git a/internal/service/loyalty_settings_service.go b/internal/service/loyalty_settings_service.go new file mode 100644 index 0000000..1d6c67a --- /dev/null +++ b/internal/service/loyalty_settings_service.go @@ -0,0 +1,117 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/google/uuid" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" +) + +// LoyaltySettingsService is the dashboard's access to loyalty settings +// (docs/prd-point-coin.md F1, F2). Every call is scoped to the caller's organization. +type LoyaltySettingsService interface { + GetOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID) *contract.Response + // UpdateOutletSettings applies a JSON body on top of the current settings: fields + // left out keep their value, and null clears an optional limit. + UpdateOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID, body []byte) *contract.Response +} + +type LoyaltySettingsServiceImpl struct { + settings *processor.LoyaltySettingsProcessor +} + +func NewLoyaltySettingsService(settings *processor.LoyaltySettingsProcessor) *LoyaltySettingsServiceImpl { + return &LoyaltySettingsServiceImpl{settings: settings} +} + +func (s *LoyaltySettingsServiceImpl) GetOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID) *contract.Response { + current, err := s.settings.OutletForOrganization(ctx, apctx.OrganizationID, outletID) + if err != nil { + return loyaltyErrorResponse(err) + } + view, err := s.outletView(ctx, apctx.OrganizationID, outletID, *current, nil) + if err != nil { + return loyaltyErrorResponse(err) + } + return contract.BuildSuccessResponse(view) +} + +// outletSettingsInput is what PUT accepts: the settings, plus the read-only fields of +// the GET response so a client can send back what it received. Those are ignored. +type outletSettingsInput struct { + *models.OutletLoyaltySettings + OutletID json.RawMessage `json:"outlet_id"` + PointValue json.RawMessage `json:"point_value"` + PointCashbackPercent json.RawMessage `json:"point_cashback_percent"` + Changes json.RawMessage `json:"changes"` +} + +func (s *LoyaltySettingsServiceImpl) UpdateOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID, body []byte) *contract.Response { + current, err := s.settings.OutletForOrganization(ctx, apctx.OrganizationID, outletID) + if err != nil { + return loyaltyErrorResponse(err) + } + + next := *current + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&outletSettingsInput{OutletLoyaltySettings: &next}); err != nil { + return contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.LoyaltySettingsServiceEntity, fmt.Sprintf("invalid request body: %v", err)), + }) + } + + changes, err := s.settings.UpdateOutlet(ctx, apctx.OrganizationID, outletID, apctx.UserID, next) + if err != nil { + return loyaltyErrorResponse(err) + } + saved, err := s.settings.Outlet(ctx, outletID) + if err != nil { + return loyaltyErrorResponse(err) + } + view, err := s.outletView(ctx, apctx.OrganizationID, outletID, *saved, changes) + if err != nil { + return loyaltyErrorResponse(err) + } + return contract.BuildSuccessResponse(view) +} + +func (s *LoyaltySettingsServiceImpl) outletView(ctx context.Context, organizationID, outletID uuid.UUID, settings models.OutletLoyaltySettings, changes []models.LoyaltySettingChange) (*models.OutletLoyaltySettingsView, error) { + pointValue, err := s.settings.PointValue(ctx, organizationID) + if err != nil { + return nil, err + } + if changes == nil { + changes = []models.LoyaltySettingChange{} + } + return &models.OutletLoyaltySettingsView{ + OutletID: outletID, + OutletLoyaltySettings: settings, + PointValue: pointValue, + PointCashbackPercent: models.LoyaltyCashbackPercent(settings.Point.EarnValue, pointValue, settings.Point.EarnPerAmount), + Changes: changes, + }, nil +} + +func loyaltyErrorResponse(err error) *contract.Response { + code, message := constants.InternalServerErrorCode, err.Error() + switch { + case errors.Is(err, repository.ErrLoyaltyOutletNotFound): + code, message = constants.NotFoundErrorCode, "outlet not found" + case errors.Is(err, processor.ErrInvalidLoyaltySettings): + code = constants.ValidationErrorCode + } + return contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(code, constants.LoyaltySettingsServiceEntity, message), + }) +} -- 2.54.0 From 5baed18b223793df0b021e5a046010919d996ee4 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 10:31:09 +0700 Subject: [PATCH 22/47] feat(loyalty): earning calculator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CalculateEarning (docs/prd-point-coin.md F1, Q1, Q10, PC-202), a pure function returning, per currency, the amount an order earns and the settings that produced it, plus the basis: basis = subtotal − discount − paid with EnakPoint (never negative) amount = 0 below min_order_amount, else floor(basis / earn_per_amount) × earn_value, capped by max_per_order Tax and anything added on top of the subtotal are not part of the basis, and the part paid with EnakPoint earns nothing. Money is handled in whole cents: in float64 some baskets divide to 4956.999… and a naive floor would lose a point, which a test reproduces. Metadata() gives the snapshot the EARN row will freeze. Co-Authored-By: Claude Opus 5.5 --- internal/processor/earning_calculator.go | 85 ++++++++++++ internal/processor/earning_calculator_test.go | 121 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 internal/processor/earning_calculator.go create mode 100644 internal/processor/earning_calculator_test.go diff --git a/internal/processor/earning_calculator.go b/internal/processor/earning_calculator.go new file mode 100644 index 0000000..28825c4 --- /dev/null +++ b/internal/processor/earning_calculator.go @@ -0,0 +1,85 @@ +package processor + +import ( + "math" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +// EarningLine is what an order earns in one currency, and the settings that produced +// it. The settings are kept so the ledger row can freeze them: a later change of +// setting must not change what an earning meant, and a reversal is computed with the +// same numbers (docs/prd-point-coin.md F3). +type EarningLine struct { + Amount int64 + Settings models.LoyaltyEarnSettings + // True when max_per_order cut the amount down. + Capped bool +} + +// EarningResult is what an order earns. +type EarningResult struct { + // subtotal − discount − the part paid with EnakPoint, in rupiah, never negative. + // Tax and anything else added on top of the subtotal are not part of it (Q1). + Basis float64 + Point EarningLine + Coin EarningLine +} + +// Metadata is the snapshot stored on the currency's EARN ledger row. +func (r EarningResult) Metadata(line EarningLine) entities.Metadata { + m := entities.Metadata{ + "basis": r.Basis, + "earn_per_amount": line.Settings.EarnPerAmount, + "earn_value": line.Settings.EarnValue, + "min_order_amount": line.Settings.MinOrderAmount, + "capped": line.Capped, + } + if line.Settings.MaxPerOrder != nil { + m["max_per_order"] = *line.Settings.MaxPerOrder + } + return m +} + +// CalculateEarning applies the earning formula of docs/prd-point-coin.md F1: +// +// basis = subtotal − discount_amount − paid with EnakPoint +// amount = 0 if basis < min_order_amount +// amount = floor(basis / earn_per_amount) × earn_value +// amount = min(amount, max_per_order) if max_per_order is set +// +// The part paid with EnakPoint earns nothing (Q10). Money is handled in whole cents so +// floor never lands one short on a value like 87500.00 that float64 cannot hold +// exactly. It has no side effects. +func CalculateEarning(order *entities.Order, pointPaidAmount float64, settings models.OutletLoyaltySettings) EarningResult { + basisCents := toCents(order.Subtotal) - toCents(order.DiscountAmount) - toCents(pointPaidAmount) + if basisCents < 0 { + basisCents = 0 + } + return EarningResult{ + Basis: float64(basisCents) / 100, + Point: earnLine(basisCents, settings.Point), + Coin: earnLine(basisCents, settings.Coin), + } +} + +func earnLine(basisCents int64, s models.LoyaltyEarnSettings) EarningLine { + line := EarningLine{Settings: s} + if !s.Enabled || s.EarnPerAmount <= 0 || s.EarnValue <= 0 { + return line + } + if basisCents < s.MinOrderAmount*100 { + return line + } + line.Amount = basisCents / (s.EarnPerAmount * 100) * s.EarnValue + if s.MaxPerOrder != nil && line.Amount > *s.MaxPerOrder { + line.Amount = *s.MaxPerOrder + line.Capped = true + } + return line +} + +func toCents(rupiah float64) int64 { + return int64(math.Round(rupiah * 100)) +} diff --git a/internal/processor/earning_calculator_test.go b/internal/processor/earning_calculator_test.go new file mode 100644 index 0000000..b49efab --- /dev/null +++ b/internal/processor/earning_calculator_test.go @@ -0,0 +1,121 @@ +package processor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +// 1 EnakPoint per Rp 100 and 1 EnakCoin per Rp 25.000, as in the F1 example. +func prdEarningSettings() models.OutletLoyaltySettings { + return models.OutletLoyaltySettings{ + Point: models.LoyaltyEarnSettings{Enabled: true, EarnPerAmount: 100, EarnValue: 1}, + Coin: models.LoyaltyEarnSettings{Enabled: true, EarnPerAmount: 25000, EarnValue: 1}, + } +} + +func TestCalculateEarning_PRDExample(t *testing.T) { + // Subtotal after discount Rp 87.500, paid in full in cash. + order := &entities.Order{Subtotal: 97500, DiscountAmount: 10000, TaxAmount: 9625, TotalAmount: 97125} + + got := CalculateEarning(order, 0, prdEarningSettings()) + assert.Equal(t, 87500.0, got.Basis) + assert.Equal(t, int64(875), got.Point.Amount) + assert.Equal(t, int64(3), got.Coin.Amount) + + // Rp 20.000 of it paid with EnakPoint earns nothing. + got = CalculateEarning(order, 20000, prdEarningSettings()) + assert.Equal(t, 67500.0, got.Basis) + assert.Equal(t, int64(675), got.Point.Amount) + assert.Equal(t, int64(2), got.Coin.Amount) +} + +func TestCalculateEarning_TaxIsNotPartOfTheBasis(t *testing.T) { + withoutTax := &entities.Order{Subtotal: 50000} + withTax := &entities.Order{Subtotal: 50000, TaxAmount: 5500, TotalAmount: 55500} + assert.Equal(t, CalculateEarning(withoutTax, 0, prdEarningSettings()), CalculateEarning(withTax, 0, prdEarningSettings())) + assert.Equal(t, int64(500), CalculateEarning(withTax, 0, prdEarningSettings()).Point.Amount) +} + +func TestCalculateEarning_BelowMinimum(t *testing.T) { + s := prdEarningSettings() + s.Point.MinOrderAmount = 50000 + + assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount) + assert.Equal(t, int64(500), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount, "the minimum itself earns") + // The minimum applies to the basis, after discount and EnakPoint. + assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 60000}, 15000, s).Point.Amount) + // Coin has its own minimum. + assert.Equal(t, int64(1), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Coin.Amount) +} + +func TestCalculateEarning_MaxPerOrder(t *testing.T) { + s := prdEarningSettings() + max := int64(300) + s.Point.MaxPerOrder = &max + + got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s) + assert.Equal(t, int64(300), got.Point.Amount) + assert.True(t, got.Point.Capped) + assert.Equal(t, int64(300), got.Metadata(got.Point)["max_per_order"]) + + got = CalculateEarning(&entities.Order{Subtotal: 20000}, 0, s) + assert.Equal(t, int64(200), got.Point.Amount) + assert.False(t, got.Point.Capped) + + zero := int64(0) + s.Point.MaxPerOrder = &zero + assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount) +} + +func TestCalculateEarning_DisabledEarnsNothing(t *testing.T) { + s := prdEarningSettings() + s.Point.Enabled = false + got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s) + assert.Equal(t, int64(0), got.Point.Amount) + assert.Equal(t, int64(3), got.Coin.Amount, "each currency is switched on its own") + + s.Coin.Enabled = false + got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s) + assert.Equal(t, int64(0), got.Coin.Amount) + assert.Equal(t, 87500.0, got.Basis, "the basis is still reported") + + // The defaults of an outlet that never set anything earn nothing. + var defaults models.OutletLoyaltySettings + loadLoyaltyFields(outletLoyaltyFields(&defaults), nil, "test") + got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, defaults) + assert.Equal(t, int64(0), got.Point.Amount) + assert.Equal(t, int64(0), got.Coin.Amount) +} + +func TestCalculateEarning_EdgeCases(t *testing.T) { + s := prdEarningSettings() + + // floor, not round. + assert.Equal(t, int64(875), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount) + // Values float64 cannot hold exactly do not lose a point: computed in float64 this + // basis divides to 4956.999…, which a naive floor turns into 4956. + per250 := prdEarningSettings() + per250.Point.EarnPerAmount = 250 + assert.Equal(t, int64(4957), CalculateEarning(&entities.Order{Subtotal: 1240155.48, DiscountAmount: 749.11}, 156.37, per250).Point.Amount) + // Paying more with EnakPoint than the basis leaves nothing, never a negative amount. + got := CalculateEarning(&entities.Order{Subtotal: 10000}, 15000, s) + assert.Equal(t, 0.0, got.Basis) + assert.Equal(t, int64(0), got.Point.Amount) + // earn_value multiplies. + s.Point.EarnValue = 5 + assert.Equal(t, int64(4375), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount) + // A zero earn_value earns nothing even when enabled. + s.Point.EarnValue = 0 + assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount) +} + +func TestCalculateEarning_MetadataSnapshot(t *testing.T) { + got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, prdEarningSettings()) + assert.Equal(t, entities.Metadata{ + "basis": 87500.0, "earn_per_amount": int64(100), "earn_value": int64(1), "min_order_amount": int64(0), "capped": false, + }, got.Metadata(got.Point)) +} -- 2.54.0 From 78c0c117749660ace80918025dcb0fa8638a14cf Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 10:40:05 +0700 Subject: [PATCH 23/47] feat(loyalty): earn EnakPoint and EnakCoin when an order is paid Adds earning at payment time (docs/prd-point-coin.md F3, PC-203). An order becomes fully paid through UpdateOrder, CreatePayment and both kinds of split bill. All of them now go through one OrderProcessorImpl hook, onOrderPaid, called after the payment has committed; for CreatePayment that is after its transaction, not from updateOrderStatus inside it. The hook runs detached from the caller's transaction and from the request being cancelled, and it runs synchronously so the order response can show what was earned. EarningProcessor.EarnForOrder skips orders that are not paid, are void, have no customer, or whose customer is the walk-in customer or inactive. It computes the earning with CalculateEarning, subtracting any part paid with EnakPoint (none until phase 3), and credits each currency through the wallet engine as EARN with key earn:{order_id}:{currency} and the settings snapshot in metadata. A repeat, even concurrent, credits nothing more. OnOrderPaid never fails the payment: errors and panics are logged. EarningBackfillJob is the safety net: every 30 minutes it earns for orders paid in the last three days that have no EARN row. It only looks at outlets with earning switched on and pages by (updated_at, id), so orders that correctly earned nothing cannot starve the ones that were missed. Lots from earning never expire until the expiry model is decided (F12, note N4). Adds the point payment method type constant, not yet accepted as a payment method. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 21 +- internal/constants/payment.go | 3 + internal/processor/earning_processor.go | 184 +++++++++++++++ .../processor/earning_processor_db_test.go | 221 ++++++++++++++++++ internal/processor/order_paid_hook_test.go | 181 ++++++++++++++ internal/processor/order_processor.go | 31 +++ internal/repository/earning_repository.go | 193 +++++++++++++++ internal/repository/tx_manager.go | 7 + internal/service/earning_backfill_job.go | 74 ++++++ internal/service/earning_backfill_job_test.go | 46 ++++ 10 files changed, 959 insertions(+), 2 deletions(-) create mode 100644 internal/processor/earning_processor.go create mode 100644 internal/processor/earning_processor_db_test.go create mode 100644 internal/processor/order_paid_hook_test.go create mode 100644 internal/repository/earning_repository.go create mode 100644 internal/service/earning_backfill_job.go create mode 100644 internal/service/earning_backfill_job_test.go diff --git a/internal/app/app.go b/internal/app/app.go index ea9cf0c..11093f0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -32,6 +32,7 @@ type App struct { shutdown chan os.Signal omsetScheduler *service.OmsetMilestoneScheduler walletRecon *service.WalletReconciliationJob + earningRetry *service.EarningBackfillJob } func NewApp(db *gorm.DB, redisClient *redis.Client) *App { @@ -60,6 +61,8 @@ func (a *App) Initialize(cfg *config.Config) error { repos.userRepo, processors.notificationProcessor, ) + // Earns for paid orders whose earning failed at payment time (docs/prd-point-coin.md F3) + a.earningRetry = service.NewEarningBackfillJob(processors.earningProcessor) services := a.initServices(processors, repos, cfg) validators := a.initValidators() @@ -167,6 +170,9 @@ func (a *App) Start(port string) error { if a.walletRecon != nil { a.walletRecon.Start(6 * time.Hour) } + if a.earningRetry != nil { + a.earningRetry.Start(30 * time.Minute) + } engine := a.router.Init() @@ -209,6 +215,9 @@ func (a *App) Shutdown() { if a.walletRecon != nil { a.walletRecon.Stop() } + if a.earningRetry != nil { + a.earningRetry.Stop() + } close(a.shutdown) } @@ -376,6 +385,7 @@ type processors struct { walletProcessor *processor.WalletProcessor walletAdminProcessor *processor.WalletAdminProcessor loyaltySettingsProcessor *processor.LoyaltySettingsProcessor + earningProcessor *processor.EarningProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -384,6 +394,12 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo) inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo) + orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo) + loyaltySettingsProcessor := processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager) + // Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3) + earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) + orderProcessor.SetOrderPaidHook(earningProcessor) + return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), organizationProcessor: processor.NewOrganizationProcessorImpl(repos.organizationRepo, repos.outletRepo, repos.userRepo), @@ -393,7 +409,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo, repos.productOutletPriceRepo), productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo), inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo), - orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo), + orderProcessor: orderProcessor, paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo), fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient), customerProcessor: processor.NewCustomerProcessor(repos.customerRepo), @@ -430,7 +446,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo), cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo), walletProcessor: processor.NewWalletProcessor(repos.walletRepo), - loyaltySettingsProcessor: processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager), + loyaltySettingsProcessor: loyaltySettingsProcessor, + earningProcessor: earningProcessor, walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), } } diff --git a/internal/constants/payment.go b/internal/constants/payment.go index a95330e..75138db 100644 --- a/internal/constants/payment.go +++ b/internal/constants/payment.go @@ -8,6 +8,9 @@ const ( PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet" PaymentMethodTypeQR PaymentMethodType = "qr" PaymentMethodTypeEDC PaymentMethodType = "edc" + // Paying with EnakPoint (docs/prd-point-coin.md F9). Not accepted as a payment method + // type until that phase ships. + PaymentMethodTypePoint PaymentMethodType = "point" ) type PaymentStatus string diff --git a/internal/processor/earning_processor.go b/internal/processor/earning_processor.go new file mode 100644 index 0000000..02eb649 --- /dev/null +++ b/internal/processor/earning_processor.go @@ -0,0 +1,184 @@ +package processor + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// Why an order earned nothing. +const ( + EarningSkipNotPaid = "NOT_PAID" + EarningSkipVoid = "VOID" + EarningSkipNoCustomer = "NO_CUSTOMER" + EarningSkipDefaultCustomer = "DEFAULT_CUSTOMER" + EarningSkipInactiveCustomer = "INACTIVE_CUSTOMER" + EarningSkipNothingToEarn = "NOTHING_TO_EARN" +) + +// EarningOutcome is what earning did for one order. +type EarningOutcome struct { + Points int64 + Coins int64 + // Set when the order earned nothing, to say why. + Skipped string +} + +type outletSettingsReader interface { + Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) +} + +// EarningProcessor credits EnakPoint and EnakCoin for paid orders +// (docs/prd-point-coin.md F3). +type EarningProcessor struct { + orders repository.EarningRepository + settings outletSettingsReader + wallet *WalletProcessor + tx TxRunner +} + +func NewEarningProcessor(orders repository.EarningRepository, settings outletSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor { + return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx} +} + +// OnOrderPaid is called once an order has become fully paid and the payment has +// committed. It never fails the caller: a failed earning is logged and picked up later +// by EarnMissing, and the idempotency keys make that retry safe. +func (p *EarningProcessor) OnOrderPaid(ctx context.Context, orderID uuid.UUID) { + defer func() { + if r := recover(); r != nil { + logger.NonContext.Error(fmt.Sprintf("Earning for order %s panicked; it will be retried", orderID), fmt.Errorf("%v", r)) + } + }() + if _, err := p.EarnForOrder(ctx, orderID); err != nil { + logger.NonContext.Error(fmt.Sprintf("Earning for order %s failed; it will be retried", orderID), err) + } +} + +// EarnForOrder credits what a paid order earns. Calling it again for the same order +// credits nothing more. +func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID) (*EarningOutcome, error) { + order, err := p.orders.GetOrderForEarning(ctx, orderID) + if err != nil { + return nil, err + } + if skip := earningSkipReason(order); skip != "" { + return &EarningOutcome{Skipped: skip}, nil + } + + settings, err := p.settings.Outlet(ctx, order.OutletID) + if err != nil { + return nil, err + } + pointPaid, err := p.orders.PointPaidAmount(ctx, orderID) + if err != nil { + return nil, err + } + result := CalculateEarning(&entities.Order{Subtotal: order.Subtotal, DiscountAmount: order.DiscountAmount}, pointPaid, *settings) + if result.Point.Amount == 0 && result.Coin.Amount == 0 { + return &EarningOutcome{Skipped: EarningSkipNothingToEarn}, nil + } + + outcome := &EarningOutcome{} + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + for _, c := range []struct { + currency string + line EarningLine + total *int64 + }{ + {constants.WalletCurrencyPoint, result.Point, &outcome.Points}, + {constants.WalletCurrencyCoin, result.Coin, &outcome.Coins}, + } { + if c.line.Amount == 0 { + continue + } + outletID := order.OutletID + res, err := p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{ + CustomerID: *order.CustomerID, + Currency: c.currency, + Type: constants.WalletTxTypeEarn, + Amount: c.line.Amount, + ReferenceType: constants.WalletRefTypeOrder, + ReferenceID: order.ID, + OutletID: &outletID, + Description: earningDescription(order), + Metadata: result.Metadata(c.line), + IdempotencyKey: fmt.Sprintf("earn:%s:%s", order.ID, c.currency), + // Lots never expire until the expiry model is decided (F12, note N4). + }}) + if err != nil { + return fmt.Errorf("crediting %s: %w", c.currency, err) + } + *c.total = res.Transaction.Amount + } + return nil + }) + if err != nil { + return nil, err + } + return outcome, nil +} + +// EarnMissing is the safety net behind OnOrderPaid: it looks for orders paid since the +// given time that should have earned and have no EARN row, and earns for them. It +// returns how many orders it looked at and how many now earned. One order failing does +// not stop the others. +func (p *EarningProcessor) EarnMissing(ctx context.Context, since time.Time, maxOrders int) (checked, earned int, err error) { + const page = 200 + var after *repository.EarningCursor + for checked < maxOrders { + batch, err := p.orders.ListPaidOrdersWithoutEarning(ctx, since, after, page) + if err != nil { + return checked, earned, err + } + if len(batch) == 0 { + break + } + for _, candidate := range batch { + checked++ + outcome, err := p.EarnForOrder(ctx, candidate.ID) + if err != nil { + logger.NonContext.Error(fmt.Sprintf("Earning retry for order %s failed", candidate.ID), err) + continue + } + if outcome.Skipped == "" { + earned++ + } + } + last := batch[len(batch)-1] + after = &last + } + return checked, earned, nil +} + +func earningSkipReason(order *repository.EarningOrder) string { + switch { + case order.PaymentStatus != string(entities.PaymentStatusCompleted): + return EarningSkipNotPaid + case order.IsVoid: + return EarningSkipVoid + case order.CustomerID == nil || order.CustomerIsDefault == nil: + return EarningSkipNoCustomer + case *order.CustomerIsDefault: + return EarningSkipDefaultCustomer + case order.CustomerIsActive == nil || !*order.CustomerIsActive: + return EarningSkipInactiveCustomer + } + return "" +} + +func earningDescription(order *repository.EarningOrder) string { + description := "Belanja #" + order.OrderNumber + if order.OutletName != "" { + description += " di " + order.OutletName + } + return truncateRunes(description, walletDescriptionLimit) +} diff --git a/internal/processor/earning_processor_db_test.go b/internal/processor/earning_processor_db_test.go new file mode 100644 index 0000000..ae6565f --- /dev/null +++ b/internal/processor/earning_processor_db_test.go @@ -0,0 +1,221 @@ +package processor + +import ( + "context" + "errors" + "os" + "sync" + "testing" + "time" + + "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/models" + "apskel-pos-be/internal/repository" +) + +// failingSettings fails for the outlet settings until healed, to stand in for the +// database being unreachable right after a payment. +type failingSettings struct { + real outletSettingsReader + mu sync.Mutex + fail bool +} + +func (f *failingSettings) Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) { + f.mu.Lock() + fail := f.fail + f.mu.Unlock() + if fail { + return nil, errors.New("connection refused") + } + return f.real.Outlet(ctx, outletID) +} + +// Needs TEST_DATABASE_URL pointing at a migrated database; see +// internal/repository/wallet_repository_test.go. +func TestEarningProcessor_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, user := uuid.New(), uuid.New() + earningOutlet, quietOutlet := uuid.New(), uuid.New() + regular, inactive := uuid.New(), uuid.New() + var walkIn uuid.UUID + var customers []uuid.UUID + 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 (?, 'earning test', 'basic')`, org) + exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, user, org, user.String()+"@test") + exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang'), (?, ?, 'Tanpa Poin')`, earningOutlet, org, quietOutlet, org) + exec(`INSERT INTO customers (id, organization_id, name, is_default, is_active) VALUES + (?, ?, 'Budi', false, true), (?, ?, 'Nonaktif', false, false)`, regular, org, inactive, org) + // Creating the organization created its walk-in customer (trigger_create_default_customer). + var walkInID string + require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, org).Scan(&walkInID).Error) + walkIn = uuid.MustParse(walkInID) + customers = []uuid.UUID{regular, walkIn, inactive} + 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 orders WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM outlet_settings WHERE outlet_id IN ?`, []uuid.UUID{earningOutlet, quietOutlet}) + db.Exec(`DELETE FROM customers WHERE id IN ?`, customers) + db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{earningOutlet, quietOutlet}) + db.Exec(`DELETE FROM users WHERE id = ?`, user) + db.Exec(`DELETE FROM organizations WHERE id = ?`, org) + }) + + txm := repository.NewTxManager(db) + settingsProcessor := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) + outletSettings, err := settingsProcessor.Outlet(ctx, earningOutlet) + require.NoError(t, err) + outletSettings.Point.Enabled = true + outletSettings.Coin.Enabled = true + _, err = settingsProcessor.UpdateOutlet(ctx, org, earningOutlet, user, *outletSettings) + require.NoError(t, err) + + settings := &failingSettings{real: settingsProcessor} + earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, NewWalletProcessor(repository.NewWalletRepository(db)), txm) + + orderNo := 0 + newOrder := func(outlet uuid.UUID, customer *uuid.UUID, paymentStatus string, isVoid bool, subtotal, discount float64) uuid.UUID { + t.Helper() + orderNo++ + id := uuid.New() + exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type, + subtotal, discount_amount, tax_amount, total_amount, payment_status, is_void) + VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, ?, 0, ?, ?, ?)`, + id, org, outlet, user, customer, id.String()[:8]+"-"+string(rune('A'+orderNo)), subtotal, discount, subtotal-discount, paymentStatus, isVoid) + return id + } + earnRows := func(orderID uuid.UUID) map[string]int64 { + t.Helper() + var rows []struct { + Currency string + Amount int64 + } + require.NoError(t, db.Raw(`SELECT currency, amount FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, orderID).Scan(&rows).Error) + out := map[string]int64{} + for _, r := range rows { + out[r.Currency] += r.Amount + } + return out + } + + // Paid in full: the PRD example, 875 EnakPoint and 3 EnakCoin. + paid := newOrder(earningOutlet, ®ular, "completed", false, 97500, 10000) + earning.OnOrderPaid(ctx, paid) + assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid)) + + var row struct { + Description string + Metadata string + OutletID string + } + require.NoError(t, db.Raw(`SELECT description, metadata::text AS metadata, outlet_id::text AS outlet_id FROM wallet_transactions + WHERE reference_id = ? AND currency = 'POINT'`, paid).Scan(&row).Error) + assert.Contains(t, row.Description, "di Kemang") + assert.Contains(t, row.Metadata, `"earn_per_amount": 100`, "the settings used are frozen on the row") + assert.Contains(t, row.Metadata, `"basis": 87500`) + assert.Equal(t, earningOutlet.String(), row.OutletID) + + // Called again, and five times at once, it is still one earning. + var wg sync.WaitGroup + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { defer wg.Done(); earning.OnOrderPaid(ctx, paid) }() + } + wg.Wait() + outcome, err := earning.EarnForOrder(ctx, paid) + require.NoError(t, err) + assert.Equal(t, int64(875), outcome.Points, "a repeat reports the first earning") + assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid)) + + // A self-order goes through the same payment path and the same rules. + selfOrder := newOrder(earningOutlet, ®ular, "completed", false, 25000, 0) + earning.OnOrderPaid(ctx, selfOrder) + assert.Equal(t, map[string]int64{"POINT": 250, "COIN": 1}, earnRows(selfOrder)) + + // A split bill earns once, on the payment that settles it: while partial, nothing. + split := newOrder(earningOutlet, ®ular, "partial", false, 60000, 0) + earning.OnOrderPaid(ctx, split) + assert.Empty(t, earnRows(split)) + exec(`UPDATE orders SET payment_status = 'completed' WHERE id = ?`, split) + earning.OnOrderPaid(ctx, split) + assert.Equal(t, map[string]int64{"POINT": 600, "COIN": 2}, earnRows(split)) + + // Orders that must not earn. + for name, c := range map[string]struct { + id uuid.UUID + skip string + }{ + "walk-in customer": {newOrder(earningOutlet, &walkIn, "completed", false, 50000, 0), EarningSkipDefaultCustomer}, + "inactive customer": {newOrder(earningOutlet, &inactive, "completed", false, 50000, 0), EarningSkipInactiveCustomer}, + "no customer": {newOrder(earningOutlet, nil, "completed", false, 50000, 0), EarningSkipNoCustomer}, + "void": {newOrder(earningOutlet, ®ular, "completed", true, 50000, 0), EarningSkipVoid}, + "unpaid": {newOrder(earningOutlet, ®ular, "pending", false, 50000, 0), EarningSkipNotPaid}, + "outlet not earning": {newOrder(quietOutlet, ®ular, "completed", false, 50000, 0), EarningSkipNothingToEarn}, + } { + outcome, err := earning.EarnForOrder(ctx, c.id) + require.NoError(t, err, name) + assert.Equal(t, c.skip, outcome.Skipped, name) + assert.Empty(t, earnRows(c.id), name) + } + + // An earning that fails does not surface to the payment, and the job picks it up. + settings.mu.Lock() + settings.fail = true + settings.mu.Unlock() + missed := newOrder(earningOutlet, ®ular, "completed", false, 40000, 0) + assert.NotPanics(t, func() { earning.OnOrderPaid(ctx, missed) }) + assert.Empty(t, earnRows(missed)) + + settings.mu.Lock() + settings.fail = false + settings.mu.Unlock() + since := time.Now().Add(-time.Hour) + checked, earned, err := earning.EarnMissing(ctx, since, 1000) + require.NoError(t, err) + assert.GreaterOrEqual(t, earned, 1) + assert.GreaterOrEqual(t, checked, earned) + assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed)) + + // The job only looks at orders that could earn and have not. + candidates, err := repository.NewEarningRepository(db).ListPaidOrdersWithoutEarning(ctx, since, nil, 1000) + require.NoError(t, err) + var ours []uuid.UUID + for _, c := range candidates { + var n int64 + db.Raw(`SELECT COUNT(*) FROM orders WHERE id = ? AND organization_id = ?`, c.ID, org).Scan(&n) + if n > 0 { + ours = append(ours, c.ID) + } + } + assert.Empty(t, ours, "every eligible order of ours has earned; walk-in, inactive, void, unpaid and non-earning outlets are never candidates") + + // A second run finds nothing more to do for these orders. + _, _, err = earning.EarnMissing(ctx, since, 1000) + require.NoError(t, err) + assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed)) + + balance := struct{ PointBalance, CoinBalance int64 }{} + require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, regular).Scan(&balance).Error) + assert.Equal(t, int64(875+250+600+400), balance.PointBalance) + assert.Equal(t, int64(3+1+2+1), balance.CoinBalance) +} diff --git a/internal/processor/order_paid_hook_test.go b/internal/processor/order_paid_hook_test.go new file mode 100644 index 0000000..b2ab4ba --- /dev/null +++ b/internal/processor/order_paid_hook_test.go @@ -0,0 +1,181 @@ +package processor + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// These fakes embed the interface they stand for and implement only what the paths +// under test call; anything else would panic, which would show a path doing more than +// expected. +type hookOrderRepo struct { + OrderRepository + order *entities.Order + statusUpdates int + hookCallsAtPay int // hook calls seen when the status was written + hook *orderPaidHookFake +} + +func (r *hookOrderRepo) GetByID(context.Context, uuid.UUID) (*entities.Order, error) { + o := *r.order + return &o, nil +} + +func (r *hookOrderRepo) GetWithRelations(context.Context, uuid.UUID) (*entities.Order, error) { + o := *r.order + return &o, nil +} + +func (r *hookOrderRepo) UpdateStatusSuccess(_ context.Context, _ uuid.UUID, status entities.OrderStatus, payment entities.PaymentStatus) error { + r.statusUpdates++ + r.hookCallsAtPay = len(r.hook.calls) + r.order.Status, r.order.PaymentStatus = status, payment + return nil +} + +type hookPaymentRepo struct { + PaymentRepository + created []*entities.Payment +} + +func (r *hookPaymentRepo) GetTotalPaidByOrderID(context.Context, uuid.UUID) (float64, error) { + return 0, nil +} + +func (r *hookPaymentRepo) Create(_ context.Context, p *entities.Payment) error { + p.ID = uuid.New() + r.created = append(r.created, p) + return nil +} + +func (r *hookPaymentRepo) GetByID(_ context.Context, id uuid.UUID) (*entities.Payment, error) { + for _, p := range r.created { + if p.ID == id { + return p, nil + } + } + return nil, nil +} + +func (r *hookPaymentRepo) GetByOrderID(context.Context, uuid.UUID) ([]*entities.Payment, error) { + return r.created, nil +} + +type hookPaymentMethodRepo struct{} + +func (hookPaymentMethodRepo) GetByID(_ context.Context, id uuid.UUID) (*entities.PaymentMethod, error) { + return &entities.PaymentMethod{ID: id}, nil +} + +type hookOrderItemRepo struct{ OrderItemRepository } + +func (hookOrderItemRepo) GetByOrderID(context.Context, uuid.UUID) ([]*entities.OrderItem, error) { + return nil, nil +} + +// splitFake settles the order on the payment that covers what is left, as the real +// split bill processor does. +type splitFake struct{ settle bool } + +func (f splitFake) split(order *entities.Order) (*models.SplitBillResponse, error) { + if f.settle { + order.PaymentStatus = entities.PaymentStatusCompleted + } else { + order.PaymentStatus = entities.PaymentStatusPartial + } + return &models.SplitBillResponse{OrderID: order.ID}, nil +} + +func (f splitFake) SplitByAmount(_ context.Context, _ *models.SplitBillRequest, order *entities.Order, _ *entities.PaymentMethod, _ *entities.Customer) (*models.SplitBillResponse, error) { + return f.split(order) +} + +func (f splitFake) SplitByItem(_ context.Context, _ *models.SplitBillRequest, order *entities.Order, _ *entities.PaymentMethod, _ *entities.Customer) (*models.SplitBillResponse, error) { + return f.split(order) +} + +type orderPaidHookFake struct { + calls []uuid.UUID + ctxs []context.Context +} + +func (h *orderPaidHookFake) OnOrderPaid(ctx context.Context, orderID uuid.UUID) { + h.calls = append(h.calls, orderID) + h.ctxs = append(h.ctxs, ctx) +} + +func newHookedOrderProcessor(split SplitBillProcessor) (*OrderProcessorImpl, *hookOrderRepo, *orderPaidHookFake) { + hook := &orderPaidHookFake{} + orders := &hookOrderRepo{ + order: &entities.Order{ID: uuid.New(), OrganizationID: uuid.New(), OutletID: uuid.New(), TotalAmount: 100000, PaymentStatus: entities.PaymentStatusPending}, + hook: hook, + } + p := &OrderProcessorImpl{ + orderRepo: orders, + orderItemRepo: hookOrderItemRepo{}, + paymentRepo: &hookPaymentRepo{}, + paymentMethodRepo: hookPaymentMethodRepo{}, + splitBillProcessor: split, + txManager: repository.NewTxManager(nil), + } + p.SetOrderPaidHook(hook) + return p, orders, hook +} + +func TestOrderPaidHook_CreatePayment(t *testing.T) { + p, orders, hook := newHookedOrderProcessor(nil) + ctx, cancel := context.WithCancel(context.Background()) + + _, err := p.CreatePayment(ctx, &models.CreatePaymentRequest{OrderID: orders.order.ID, PaymentMethodID: uuid.New(), Amount: 100000}) + require.NoError(t, err) + assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) + assert.Zero(t, orders.hookCallsAtPay, "the hook runs after the payment, not inside its transaction") + + // The hook's context outlives the request. + cancel() + assert.NoError(t, hook.ctxs[0].Err()) +} + +func TestOrderPaidHook_UpdateOrder(t *testing.T) { + p, orders, hook := newHookedOrderProcessor(nil) + _, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{}) + require.NoError(t, err) + assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) + assert.Equal(t, 1, orders.statusUpdates) + assert.Zero(t, orders.hookCallsAtPay) +} + +func TestOrderPaidHook_SplitBillOnlyOnTheSettlingPayment(t *testing.T) { + for _, splitType := range []string{"AMOUNT", "ITEM"} { + t.Run(splitType, func(t *testing.T) { + req := &models.SplitBillRequest{Type: splitType, PaymentMethodID: uuid.New()} + + p, orders, hook := newHookedOrderProcessor(splitFake{settle: false}) + req.OrderID = orders.order.ID + _, err := p.SplitBill(context.Background(), req) + require.NoError(t, err) + assert.Empty(t, hook.calls, "a partial split payment does not make the order paid") + + p, orders, hook = newHookedOrderProcessor(splitFake{settle: true}) + req.OrderID = orders.order.ID + _, err = p.SplitBill(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) + }) + } +} + +func TestOrderPaidHook_NoHookIsFine(t *testing.T) { + p, orders, _ := newHookedOrderProcessor(nil) + p.SetOrderPaidHook(nil) + _, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{}) + assert.NoError(t, err) +} diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 3e14e23..6cf9eb0 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -109,6 +109,30 @@ type OrderProcessorImpl struct { ingredientRepo IngredientRepository inventoryMovementService InventoryMovementService productOutletPriceRepo repository.ProductOutletPriceRepository + orderPaidHook OrderPaidHook +} + +// OrderPaidHook is told when an order has just become fully paid and the payment has +// committed. EarningProcessor is one (docs/prd-point-coin.md F3). +type OrderPaidHook interface { + OnOrderPaid(ctx context.Context, orderID uuid.UUID) +} + +// SetOrderPaidHook sets what runs when an order becomes fully paid. +func (p *OrderProcessorImpl) SetOrderPaidHook(hook OrderPaidHook) { + p.orderPaidHook = hook +} + +// onOrderPaid is the single place every path that completes an order's payment goes +// through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called +// after the payment has committed. The hook runs detached from the caller's +// transaction and from the request being cancelled, and anything it does cannot fail +// the payment. +func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID) { + if p.orderPaidHook == nil { + return + } + p.orderPaidHook.OnOrderPaid(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID) } func NewOrderProcessorImpl( @@ -494,6 +518,7 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req if err := p.orderRepo.UpdateStatusSuccess(ctx, order.ID, order.Status, order.PaymentStatus); err != nil { return nil, fmt.Errorf("failed to update order: %w", err) } + p.onOrderPaid(ctx, order.ID) orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, id) if err != nil { @@ -818,6 +843,8 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea if err != nil { return nil, err } + // Not from updateOrderStatus: that runs inside the payment's transaction. + p.onOrderPaid(ctx, req.OrderID) paymentWithRelations, err := p.paymentRepo.GetByID(ctx, payment.ID) if err != nil { @@ -1207,6 +1234,10 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil if err != nil { return nil, err } + // Both split paths mark the order paid on the payment that settles it. + if order.PaymentStatus == entities.PaymentStatusCompleted { + p.onOrderPaid(ctx, order.ID) + } return response, nil } diff --git a/internal/repository/earning_repository.go b/internal/repository/earning_repository.go new file mode 100644 index 0000000..f583c9d --- /dev/null +++ b/internal/repository/earning_repository.go @@ -0,0 +1,193 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" +) + +// ErrEarningOrderNotFound means the order does not exist. +var ErrEarningOrderNotFound = errors.New("earning: order not found") + +// EarningOrder is what earning needs to know about an order. +type EarningOrder struct { + ID uuid.UUID + OrganizationID uuid.UUID + OutletID uuid.UUID + OrderNumber string + OutletName string + CustomerID *uuid.UUID + Subtotal float64 + DiscountAmount float64 + PaymentStatus string + IsVoid bool + // Nil when the order has no customer, or the customer row is gone. + CustomerIsDefault *bool + CustomerIsActive *bool +} + +// EarningCursor pages through orders by (updated_at, id). +type EarningCursor struct { + UpdatedAt time.Time + ID uuid.UUID +} + +// EarningRepository reads orders for loyalty earning (docs/prd-point-coin.md F3). +type EarningRepository interface { + GetOrderForEarning(ctx context.Context, orderID uuid.UUID) (*EarningOrder, error) + // PointPaidAmount is the rupiah part of the order paid with EnakPoint, which earns + // nothing (Q10). Zero until EnakPoint payment exists (phase 3). + PointPaidAmount(ctx context.Context, orderID uuid.UUID) (float64, error) + // ListPaidOrdersWithoutEarning pages, oldest first, through orders updated since + // the given time that are paid, not void, have an eligible customer, belong to an + // outlet that earns something, and have no EARN row yet. Pass the previous page's + // last cursor to continue; nil starts at the beginning. + ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error) + // ListEarnTransactions returns the EARN rows written for an order. + ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error) +} + +type earningRepository struct { + db *gorm.DB +} + +func NewEarningRepository(db *gorm.DB) EarningRepository { + return &earningRepository{db: db} +} + +func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid.UUID) (*EarningOrder, error) { + var rows []struct { + ID string + OrganizationID string + OutletID string + OrderNumber string + OutletName string + CustomerID *string + Subtotal float64 + DiscountAmount float64 + PaymentStatus string + IsVoid bool + CustomerIsDefault *bool + CustomerIsActive *bool + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id, + o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id, + o.subtotal, COALESCE(o.discount_amount, 0) AS discount_amount, o.payment_status, + COALESCE(o.is_void, false) AS is_void, + c.is_default AS customer_is_default, c.is_active AS customer_is_active + FROM orders o + LEFT JOIN outlets ou ON ou.id = o.outlet_id + LEFT JOIN customers c ON c.id = o.customer_id + WHERE o.id = ? + LIMIT 1`, orderID).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to get order for earning: %w", err) + } + if len(rows) == 0 { + return nil, ErrEarningOrderNotFound + } + row := rows[0] + order := &EarningOrder{ + OrderNumber: row.OrderNumber, + OutletName: row.OutletName, + Subtotal: row.Subtotal, + DiscountAmount: row.DiscountAmount, + PaymentStatus: row.PaymentStatus, + IsVoid: row.IsVoid, + CustomerIsDefault: row.CustomerIsDefault, + CustomerIsActive: row.CustomerIsActive, + } + order.ID, _ = uuid.Parse(row.ID) + order.OrganizationID, _ = uuid.Parse(row.OrganizationID) + order.OutletID, _ = uuid.Parse(row.OutletID) + if row.CustomerID != nil { + if id, err := uuid.Parse(*row.CustomerID); err == nil { + order.CustomerID = &id + } + } + return order, nil +} + +func (r *earningRepository) PointPaidAmount(ctx context.Context, orderID uuid.UUID) (float64, error) { + var total float64 + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT COALESCE(SUM(p.amount), 0) + FROM payments p + JOIN payment_methods pm ON pm.id = p.payment_method_id + WHERE p.order_id = ? AND pm.type = ? AND p.status = ?`, + orderID, constants.PaymentMethodTypePoint, entities.PaymentTransactionStatusCompleted). + Scan(&total).Error + if err != nil { + return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err) + } + return total, nil +} + +func (r *earningRepository) ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error) { + cursorAt, cursorID := since, uuid.Nil + if after != nil { + cursorAt, cursorID = after.UpdatedAt, after.ID + } + var rows []struct { + ID string + UpdatedAt time.Time + } + // An outlet that has neither currency switched on can never earn, so its orders are + // not candidates; otherwise every order of such an outlet would be rescanned on + // every run. + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT o.id::text AS id, o.updated_at + FROM orders o + JOIN customers c ON c.id = o.customer_id + WHERE o.payment_status = ? + AND COALESCE(o.is_void, false) = false + AND c.is_default = false AND c.is_active = true + AND o.updated_at >= ? + AND (o.updated_at, o.id) > (?, ?) + AND EXISTS ( + SELECT 1 FROM outlet_settings s + WHERE s.outlet_id = o.outlet_id + AND s.key IN (?, ?) + AND lower(trim(s.value)) IN ('true', 't', '1') + ) + AND NOT EXISTS ( + SELECT 1 FROM wallet_transactions t + WHERE t.reference_type = ? AND t.reference_id = o.id AND t.type = ? + ) + ORDER BY o.updated_at, o.id + LIMIT ?`, + entities.PaymentStatusCompleted, since, cursorAt, cursorID, + constants.LoyaltyPointEnabledKey, constants.LoyaltyCoinEnabledKey, + constants.WalletRefTypeOrder, constants.WalletTxTypeEarn, limit). + Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to list paid orders without earning: %w", err) + } + out := make([]EarningCursor, 0, len(rows)) + for _, row := range rows { + if id, err := uuid.Parse(row.ID); err == nil { + out = append(out, EarningCursor{UpdatedAt: row.UpdatedAt, ID: id}) + } + } + return out, nil +} + +func (r *earningRepository) ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error) { + var rows []entities.WalletTransaction + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("reference_type = ? AND reference_id = ? AND type = ?", constants.WalletRefTypeOrder, orderID, constants.WalletTxTypeEarn). + Order("currency"). + Find(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to list EARN rows: %w", err) + } + return rows, nil +} diff --git a/internal/repository/tx_manager.go b/internal/repository/tx_manager.go index ee9a3f8..33760c2 100644 --- a/internal/repository/tx_manager.go +++ b/internal/repository/tx_manager.go @@ -50,3 +50,10 @@ func (m *TxManager) WithTransactionOptions(ctx context.Context, opts *sql.TxOpti return fn(ctxTx) }, opts) } + +// DetachTransaction returns ctx without the caller's transaction, so work started from +// it (such as loyalty earning after a payment) reads committed data and commits on its +// own, whatever happens to the caller's transaction. +func DetachTransaction(ctx context.Context) context.Context { + return context.WithValue(ctx, txKey, (*gorm.DB)(nil)) +} diff --git a/internal/service/earning_backfill_job.go b/internal/service/earning_backfill_job.go new file mode 100644 index 0000000..131434c --- /dev/null +++ b/internal/service/earning_backfill_job.go @@ -0,0 +1,74 @@ +package service + +import ( + "context" + "sync" + "time" + + "apskel-pos-be/internal/logger" +) + +const ( + defaultEarningBackfillInterval = 30 * time.Minute + // How far back to look for paid orders that never earned. + earningBackfillWindow = 72 * time.Hour + // Orders looked at per run at most, so one run cannot run away. + earningBackfillMaxOrders = 5000 +) + +type missingEarner interface { + EarnMissing(ctx context.Context, since time.Time, maxOrders int) (checked, earned int, err error) +} + +// EarningBackfillJob is the safety net behind earning at payment time +// (docs/prd-point-coin.md F3, PC-203). Every run it earns for orders paid in the last +// few days that should have earned and did not, for example because the database was +// briefly unreachable right after the payment committed. +type EarningBackfillJob struct { + earner missingEarner + now func() time.Time + stopCh chan struct{} + stopOnce sync.Once +} + +func NewEarningBackfillJob(earner missingEarner) *EarningBackfillJob { + return &EarningBackfillJob{earner: earner, now: time.Now, stopCh: make(chan struct{})} +} + +func (j *EarningBackfillJob) Start(interval time.Duration) { + if interval <= 0 { + interval = defaultEarningBackfillInterval + } + go func() { + j.RunOnce(context.Background()) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + j.RunOnce(context.Background()) + case <-j.stopCh: + return + } + } + }() + logger.NonContext.Infof("Earning backfill job started (interval: %s)", interval) +} + +func (j *EarningBackfillJob) Stop() { + j.stopOnce.Do(func() { close(j.stopCh) }) +} + +// RunOnce earns for every missed order in the window and reports how many it fixed. +// It is quiet when nothing was missed. +func (j *EarningBackfillJob) RunOnce(ctx context.Context) int { + checked, earned, err := j.earner.EarnMissing(ctx, j.now().Add(-earningBackfillWindow), earningBackfillMaxOrders) + if err != nil { + logger.NonContext.Error("Earning backfill failed to run", err) + } + if earned > 0 { + logger.NonContext.WarnWithFields("Earning backfill credited orders that had missed their earning", + map[string]interface{}{"checked": checked, "earned": earned}, nil) + } + return earned +} diff --git a/internal/service/earning_backfill_job_test.go b/internal/service/earning_backfill_job_test.go new file mode 100644 index 0000000..31146b5 --- /dev/null +++ b/internal/service/earning_backfill_job_test.go @@ -0,0 +1,46 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "apskel-pos-be/internal/logger" +) + +type missingEarnerFake struct { + since time.Time + max int + earned int + err error + calls int +} + +func (f *missingEarnerFake) EarnMissing(_ context.Context, since time.Time, maxOrders int) (int, int, error) { + f.calls++ + f.since, f.max = since, maxOrders + return f.earned * 2, f.earned, f.err +} + +func TestEarningBackfillJob(t *testing.T) { + logger.Setup("fatal", "json") + now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + earner := &missingEarnerFake{earned: 3} + job := NewEarningBackfillJob(earner) + job.now = func() time.Time { return now } + + assert.Equal(t, 3, job.RunOnce(context.Background())) + assert.Equal(t, now.Add(-72*time.Hour), earner.since, "looks back three days") + assert.Equal(t, earningBackfillMaxOrders, earner.max) + + // A failing run is logged, not fatal. + earner.earned, earner.err = 0, errors.New("db down") + assert.Equal(t, 0, job.RunOnce(context.Background())) + + job.Start(time.Hour) + job.Stop() + job.Stop() +} -- 2.54.0 From eb5b63677f47a97fbb5ac9267ffa90dd1ce82966 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 10:45:33 +0700 Subject: [PATCH 24/47] feat(loyalty): take back earning when an order is voided or refunded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds earning reversal (docs/prd-point-coin.md F10, Q3, PC-204). VoidOrder, RefundOrder and RefundPayment now end with an onOrderRefunded hook, called once their writes have committed and, like onOrderPaid, detached from the request so it can never block or fail the void or refund. For RefundPayment that is after its transaction. EarningProcessor.ReverseForOrder computes how much of each EARN row should have come back in total: everything for a void, otherwise floor(earned × refunded / basis) with the order's cumulative refund and the basis frozen on the EARN row, never more than was earned (a refund including tax can pass the basis). It takes only what has not been asked back yet, what was taken plus any shortfall, so repeats and successive partial refunds never add up to more than the earning. It writes an EARN_REVERSAL pointing at the EARN with DebitUpTo, drawing from the lots the EARN created first, and records the shortfall when the balance was already spent. When the balance is empty there is no ledger row to carry the shortfall; that case is logged. VoidOrder still refuses fully paid orders, so a void has nothing to take back today; the hook keeps it correct if that changes. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 1 + internal/processor/earning_reversal.go | 167 ++++++++++++++ .../processor/earning_reversal_db_test.go | 206 ++++++++++++++++++ internal/processor/earning_reversal_test.go | 25 +++ internal/processor/order_paid_hook_test.go | 65 ++++++ internal/processor/order_processor.go | 33 ++- internal/repository/earning_repository.go | 43 +++- 7 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 internal/processor/earning_reversal.go create mode 100644 internal/processor/earning_reversal_db_test.go create mode 100644 internal/processor/earning_reversal_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 11093f0..f79dd26 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -399,6 +399,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor // Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3) earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) orderProcessor.SetOrderPaidHook(earningProcessor) + orderProcessor.SetOrderRefundedHook(earningProcessor) return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), diff --git a/internal/processor/earning_reversal.go b/internal/processor/earning_reversal.go new file mode 100644 index 0000000..e0dc1dd --- /dev/null +++ b/internal/processor/earning_reversal.go @@ -0,0 +1,167 @@ +package processor + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/repository" +) + +// ReversalOutcome is what a reversal took back, and what it could not. +type ReversalOutcome struct { + Points int64 + Coins int64 + PointShortfall int64 + CoinShortfall int64 +} + +// OnOrderRefunded is called after an order was voided or (partly) refunded and that +// has committed. It never fails the caller: a refund is never blocked by the loyalty +// balance (Q3), so errors are logged. +func (p *EarningProcessor) OnOrderRefunded(ctx context.Context, orderID uuid.UUID) { + defer func() { + if r := recover(); r != nil { + logger.NonContext.Error(fmt.Sprintf("Earning reversal for order %s panicked", orderID), fmt.Errorf("%v", r)) + } + }() + if _, err := p.ReverseForOrder(ctx, orderID); err != nil { + logger.NonContext.Error(fmt.Sprintf("Earning reversal for order %s failed", orderID), err) + } +} + +// ReverseForOrder takes back what an order earned, as far as it has been voided or +// refunded (docs/prd-point-coin.md F10): +// +// - void: everything the order earned; +// - refund: floor(earned × refunded / basis), never more than was earned, with the +// refunded amount being the order's cumulative refund. +// +// Only the part not asked back yet is taken, so calling it again, or after each of +// several partial refunds, never takes more than the order earned. It draws from the +// lots the EARN created first, then from the others in K9 order, and takes what the +// balance has when it is short, recording the rest as shortfall (Q3). +func (p *EarningProcessor) ReverseForOrder(ctx context.Context, orderID uuid.UUID) (*ReversalOutcome, error) { + order, err := p.orders.GetOrderForEarning(ctx, orderID) + if err != nil { + return nil, err + } + earns, err := p.orders.ListEarnTransactions(ctx, orderID) + if err != nil { + return nil, err + } + outcome := &ReversalOutcome{} + if len(earns) == 0 { + return outcome, nil + } + + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + for _, earn := range earns { + target := earningReversalTarget(order, earn) + requested, err := p.orders.ReversalRequested(ctx, earn.ID) + if err != nil { + return err + } + amount := target - requested + if amount <= 0 { + continue + } + lots, err := p.orders.LotIDsCreatedBy(ctx, earn.ID) + if err != nil { + return err + } + + reason := "REFUND" + if order.IsVoid { + reason = "VOID" + } + earnID := earn.ID + res, err := p.wallet.DebitUpTo(ctx, WalletDebitInput{ + WalletEntry: WalletEntry{ + CustomerID: earn.CustomerID, + Currency: earn.Currency, + Type: constants.WalletTxTypeEarnReversal, + Amount: amount, + ReferenceType: constants.WalletRefTypeOrder, + ReferenceID: order.ID, + ReversesTransactionID: &earnID, + OutletID: earn.OutletID, + Description: earningReversalDescription(order), + Metadata: entities.Metadata{ + "reason": reason, + "refund_amount": order.RefundAmount, + "target": target, + }, + // The target only grows with each refund, so each refund gets its own + // key while a retry of the same one replays. + IdempotencyKey: fmt.Sprintf("reverse:%s:%d", earn.ID, target), + }, + PreferredLotIDs: lots, + }) + if err != nil { + return fmt.Errorf("reversing %s: %w", earn.Currency, err) + } + + var taken int64 + if res.Transaction != nil { + taken = -res.Transaction.Amount + } else { + // Nothing to take: the ledger has no row to carry the shortfall. + logger.NonContext.WarnWithFields("Earning reversal found an empty balance; the whole amount is shortfall", map[string]interface{}{ + "order_id": order.ID.String(), "customer_id": earn.CustomerID.String(), + "currency": earn.Currency, "shortfall": res.Shortfall, + }, nil) + } + switch earn.Currency { + case constants.WalletCurrencyPoint: + outcome.Points += taken + outcome.PointShortfall += res.Shortfall + case constants.WalletCurrencyCoin: + outcome.Coins += taken + outcome.CoinShortfall += res.Shortfall + } + } + return nil + }) + if err != nil { + return nil, err + } + return outcome, nil +} + +// earningReversalTarget is how much of an EARN row should have been taken back in +// total, given the order's void flag and cumulative refund. It works in cents like +// CalculateEarning, with the basis frozen on the EARN row. +func earningReversalTarget(order *repository.EarningOrder, earn entities.WalletTransaction) int64 { + earned := earn.Amount + if order.IsVoid { + return earned + } + refundCents := toCents(order.RefundAmount) + if refundCents <= 0 { + return 0 + } + basis, _ := earn.Metadata["basis"].(float64) + basisCents := toCents(basis) + // A refund can include tax, which the basis does not, so it can reach past it. + if basisCents <= 0 || refundCents >= basisCents { + return earned + } + return earned * refundCents / basisCents +} + +func earningReversalDescription(order *repository.EarningOrder) string { + verb := "Refund" + if order.IsVoid { + verb = "Batal" + } + description := verb + " #" + order.OrderNumber + if order.OutletName != "" { + description += " di " + order.OutletName + } + return truncateRunes(description, walletDescriptionLimit) +} diff --git a/internal/processor/earning_reversal_db_test.go b/internal/processor/earning_reversal_db_test.go new file mode 100644 index 0000000..d17dd8e --- /dev/null +++ b/internal/processor/earning_reversal_db_test.go @@ -0,0 +1,206 @@ +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. +func TestEarningReversal_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, user, outlet := uuid.New(), uuid.New(), uuid.New() + voider, refunder, spender, loyal := uuid.New(), uuid.New(), uuid.New(), uuid.New() + customers := []uuid.UUID{voider, refunder, spender, loyal} + 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 (?, 'reversal test', 'basic')`, org) + exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, user, org, user.String()+"@test") + exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org) + for _, c := range customers { + exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, 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 orders WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet) + db.Exec(`DELETE FROM customers WHERE id IN ?`, customers) + db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet) + db.Exec(`DELETE FROM users WHERE id = ?`, user) + db.Exec(`DELETE FROM organizations WHERE id = ?`, org) + }) + + txm := repository.NewTxManager(db) + settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) + s, err := settings.Outlet(ctx, outlet) + require.NoError(t, err) + s.Point.Enabled, s.Coin.Enabled = true, true + _, err = settings.UpdateOutlet(ctx, org, outlet, user, *s) + require.NoError(t, err) + wallet := NewWalletProcessor(repository.NewWalletRepository(db)) + earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm) + + // Each order is the PRD example: basis Rp 87.500, 875 EnakPoint and 3 EnakCoin. + paidOrder := func(customer uuid.UUID) uuid.UUID { + t.Helper() + id := uuid.New() + exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type, + subtotal, discount_amount, tax_amount, total_amount, payment_status) + VALUES (?, ?, ?, ?, ?, ?, 'dine_in', 97500, 10000, 8750, 96250, 'completed')`, + id, org, outlet, user, customer, "ORD-"+id.String()[:8]) + outcome, err := earning.EarnForOrder(ctx, id) + require.NoError(t, err) + require.Equal(t, int64(875), outcome.Points) + return id + } + balance := func(customer uuid.UUID) (points, coins int64) { + t.Helper() + var b struct{ PointBalance, CoinBalance int64 } + require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error) + return b.PointBalance, b.CoinBalance + } + reversed := func(order uuid.UUID, currency string) int64 { + t.Helper() + var total int64 + require.NoError(t, db.Raw(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions + WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = ?`, order, currency).Scan(&total).Error) + return total + } + + // Void: everything comes back. + voided := paidOrder(voider) + exec(`UPDATE orders SET is_void = true WHERE id = ?`, voided) + earning.OnOrderRefunded(ctx, voided) + assert.Equal(t, int64(875), reversed(voided, "POINT")) + assert.Equal(t, int64(3), reversed(voided, "COIN")) + p, c := balance(voider) + assert.Equal(t, [2]int64{0, 0}, [2]int64{p, c}) + + var rev struct { + ReversesTransactionID string + Description string + } + require.NoError(t, db.Raw(`SELECT reverses_transaction_id::text AS reverses_transaction_id, description FROM wallet_transactions + WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'`, voided).Scan(&rev).Error) + var earnID string + require.NoError(t, db.Raw(`SELECT id::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN' AND currency = 'POINT'`, voided).Scan(&earnID).Error) + assert.Equal(t, earnID, rev.ReversesTransactionID, "the reversal points at the EARN it reverses") + assert.Contains(t, rev.Description, "Batal #ORD-") + + // Repeating it takes nothing more. + earning.OnOrderRefunded(ctx, voided) + assert.Equal(t, int64(875), reversed(voided, "POINT")) + + // Partial refunds are proportional and add up to at most what was earned. + refunded := paidOrder(refunder) + for _, step := range []struct { + refundAmount float64 + wantTotal int64 + }{ + {20000, 200}, // floor(875 × 20000 / 87500) + {20000, 200}, // same refund again: nothing more + {50000, 500}, // cumulative refund grew + {96250, 875}, // the whole total, tax included, cannot exceed the earning + {96250, 875}, + } { + exec(`UPDATE orders SET refund_amount = ? WHERE id = ?`, step.refundAmount, refunded) + outcome, err := earning.ReverseForOrder(ctx, refunded) + require.NoError(t, err) + assert.Zero(t, outcome.PointShortfall) + assert.Equal(t, step.wantTotal, reversed(refunded, "POINT"), "after a refund of %.0f", step.refundAmount) + } + assert.Equal(t, int64(3), reversed(refunded, "COIN")) + + // Balance already spent: what is left is taken, the rest recorded as shortfall, + // and the refund is not blocked. + spent := paidOrder(spender) + committed(t, txm, func(ctx context.Context) (*WalletResult, error) { + return wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ + CustomerID: spender, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, + Amount: 800, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet, + Description: "Bayar"}}) + }) + exec(`UPDATE orders SET is_void = true WHERE id = ?`, spent) + outcome, err := earning.ReverseForOrder(ctx, spent) + require.NoError(t, err) + assert.Equal(t, int64(75), outcome.Points) + assert.Equal(t, int64(800), outcome.PointShortfall) + p, _ = balance(spender) + assert.Equal(t, int64(0), p, "never negative") + var metadata string + require.NoError(t, db.Raw(`SELECT metadata::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'`, spent).Scan(&metadata).Error) + assert.Contains(t, metadata, `"shortfall": 800`) + assert.Contains(t, metadata, `"requested_amount": 875`) + // Asked back in full already, shortfall included: a repeat takes nothing. + outcome, err = earning.ReverseForOrder(ctx, spent) + require.NoError(t, err) + assert.Zero(t, outcome.Points) + + // The EARN's own lots are drawn first, even when an older lot would come first. + older := committed(t, txm, func(ctx context.Context) (*WalletResult, error) { + return wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{ + CustomerID: loyal, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, + Amount: 500, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}}) + }) + loyalOrder := paidOrder(loyal) + exec(`UPDATE orders SET refund_amount = 20000 WHERE id = ?`, loyalOrder) + _, err = earning.ReverseForOrder(ctx, loyalOrder) + require.NoError(t, err) + var fromOlder int64 + require.NoError(t, db.Raw(`SELECT COALESCE(SUM(a.amount), 0) FROM wallet_lot_allocations a + JOIN wallet_transactions t ON t.id = a.transaction_id + WHERE t.reference_id = ? AND t.type = 'EARN_REVERSAL' AND a.lot_id = ?`, loyalOrder, older.Lots[0].ID).Scan(&fromOlder).Error) + assert.Zero(t, fromOlder, "the older lot is untouched") + assert.Equal(t, int64(200), reversed(loyalOrder, "POINT")) + + // An order that never earned has nothing to reverse. + outcome, err = earning.ReverseForOrder(ctx, uuid.New()) + assert.ErrorIs(t, err, repository.ErrEarningOrderNotFound) + assert.Nil(t, outcome) + + // Every reversal kept balances, ledger and lots in step (§7.5). + found, err := repository.NewWalletReconciliationRepository(db).FindDiscrepancies(ctx, 1000) + require.NoError(t, err) + for _, d := range found { + for _, c := range customers { + assert.NotEqual(t, c, d.CustomerID, "discrepancy %s for one of our customers", d.Check) + } + } +} + +// committed runs fn in a transaction that commits before it returns, for engine calls +// made directly from a test. +func committed[T any](t *testing.T, txm *repository.TxManager, fn func(ctx context.Context) (T, error)) T { + t.Helper() + var out T + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + var err error + out, err = fn(ctx) + return err + })) + return out +} diff --git a/internal/processor/earning_reversal_test.go b/internal/processor/earning_reversal_test.go new file mode 100644 index 0000000..760fae4 --- /dev/null +++ b/internal/processor/earning_reversal_test.go @@ -0,0 +1,25 @@ +package processor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/repository" +) + +func TestEarningReversalTarget(t *testing.T) { + earn := entities.WalletTransaction{Amount: 875, Metadata: entities.Metadata{"basis": 87500.0}} + target := func(order repository.EarningOrder) int64 { return earningReversalTarget(&order, earn) } + + assert.Equal(t, int64(0), target(repository.EarningOrder{}), "nothing refunded, nothing to take") + assert.Equal(t, int64(875), target(repository.EarningOrder{IsVoid: true}), "void takes everything") + assert.Equal(t, int64(200), target(repository.EarningOrder{RefundAmount: 20000}), "floor(875 × 20000 / 87500)") + assert.Equal(t, int64(299), target(repository.EarningOrder{RefundAmount: 29999.99}), "floor, not round") + assert.Equal(t, int64(875), target(repository.EarningOrder{RefundAmount: 87500})) + assert.Equal(t, int64(875), target(repository.EarningOrder{RefundAmount: 96250}), "a refund including tax cannot take more than was earned") + + noBasis := entities.WalletTransaction{Amount: 10, Metadata: entities.Metadata{}} + assert.Equal(t, int64(10), earningReversalTarget(&repository.EarningOrder{RefundAmount: 1}, noBasis), "without a basis any refund takes it all") +} diff --git a/internal/processor/order_paid_hook_test.go b/internal/processor/order_paid_hook_test.go index b2ab4ba..964ca49 100644 --- a/internal/processor/order_paid_hook_test.go +++ b/internal/processor/order_paid_hook_test.go @@ -179,3 +179,68 @@ func TestOrderPaidHook_NoHookIsFine(t *testing.T) { _, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{}) assert.NoError(t, err) } + +func (r *hookOrderRepo) VoidOrderWithStatus(_ context.Context, _ uuid.UUID, status entities.OrderStatus, _ string, _ uuid.UUID) error { + r.order.Status, r.order.IsVoid = status, true + return nil +} + +func (r *hookOrderRepo) Update(_ context.Context, o *entities.Order) error { + r.order.RefundAmount = o.RefundAmount + return nil +} + +func (r *hookOrderRepo) RefundOrder(context.Context, uuid.UUID, string, uuid.UUID) error { + r.order.IsRefund = true + return nil +} + +func (r *hookPaymentRepo) RefundPayment(_ context.Context, id uuid.UUID, amount float64, _ string, _ uuid.UUID) error { + for _, p := range r.created { + if p.ID == id { + p.RefundAmount = amount + } + } + return nil +} + +type orderRefundedHookFake struct{ calls []uuid.UUID } + +func (h *orderRefundedHookFake) OnOrderRefunded(_ context.Context, orderID uuid.UUID) { + h.calls = append(h.calls, orderID) +} + +func TestOrderRefundedHook(t *testing.T) { + t.Run("void", func(t *testing.T) { + p, orders, _ := newHookedOrderProcessor(nil) + hook := &orderRefundedHookFake{} + p.SetOrderRefundedHook(hook) + require.NoError(t, p.VoidOrder(context.Background(), &models.VoidOrderRequest{OrderID: orders.order.ID, Type: "ALL", Reason: "salah input"}, uuid.New())) + assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) + }) + + t.Run("refund order", func(t *testing.T) { + p, orders, _ := newHookedOrderProcessor(nil) + orders.order.PaymentStatus = entities.PaymentStatusCompleted + hook := &orderRefundedHookFake{} + p.SetOrderRefundedHook(hook) + amount := 25000.0 + require.NoError(t, p.RefundOrder(context.Background(), orders.order.ID, &models.RefundOrderRequest{RefundAmount: &amount}, uuid.New())) + assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) + }) + + t.Run("refund payment", func(t *testing.T) { + p, orders, _ := newHookedOrderProcessor(nil) + payments := p.paymentRepo.(*hookPaymentRepo) + payment := &entities.Payment{ID: uuid.New(), OrderID: orders.order.ID, Amount: 50000, Status: entities.PaymentTransactionStatusCompleted} + payments.created = append(payments.created, payment) + hook := &orderRefundedHookFake{} + p.SetOrderRefundedHook(hook) + require.NoError(t, p.RefundPayment(context.Background(), payment.ID, 20000, "kembali", uuid.New())) + assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) + + // A refund that fails does not run the hook. + assert.Error(t, p.RefundPayment(context.Background(), payment.ID, 60000, "terlalu besar", uuid.New())) + assert.Len(t, hook.calls, 1) + }) +} diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 6cf9eb0..2c6426c 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -110,6 +110,7 @@ type OrderProcessorImpl struct { inventoryMovementService InventoryMovementService productOutletPriceRepo repository.ProductOutletPriceRepository orderPaidHook OrderPaidHook + orderRefundedHook OrderRefundedHook } // OrderPaidHook is told when an order has just become fully paid and the payment has @@ -123,6 +124,27 @@ func (p *OrderProcessorImpl) SetOrderPaidHook(hook OrderPaidHook) { p.orderPaidHook = hook } +// OrderRefundedHook is told when an order has been voided or (partly) refunded and +// that has committed. EarningProcessor is one (docs/prd-point-coin.md F10). +type OrderRefundedHook interface { + OnOrderRefunded(ctx context.Context, orderID uuid.UUID) +} + +// SetOrderRefundedHook sets what runs when an order is voided or refunded. +func (p *OrderProcessorImpl) SetOrderRefundedHook(hook OrderRefundedHook) { + p.orderRefundedHook = hook +} + +// onOrderRefunded is called at the end of VoidOrder, RefundOrder and RefundPayment, +// after their writes have committed, with the same guarantees as onOrderPaid: it can +// never block or fail the void or refund. +func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) { + if p.orderRefundedHook == nil { + return + } + p.orderRefundedHook.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID) +} + // onOrderPaid is the single place every path that completes an order's payment goes // through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called // after the payment has committed. The hook runs detached from the caller's @@ -717,6 +739,10 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde return fmt.Errorf("invalid void type: must be 'ALL' or 'ITEM'") } + // VoidOrder refuses fully paid orders today, so there is usually nothing to take + // back; this keeps a void correct if that rule changes. The order repository writes + // outside the caller's transaction, so the void is already committed here. + p.onOrderRefunded(ctx, req.OrderID) return nil } @@ -812,6 +838,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req } } + p.onOrderRefunded(ctx, id) return nil } @@ -869,7 +896,11 @@ func (p *OrderProcessorImpl) RefundPayment(ctx context.Context, paymentID uuid.U return fmt.Errorf("refund amount cannot exceed payment amount") } - return p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment) + if err := p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment); err != nil { + return err + } + p.onOrderRefunded(ctx, payment.OrderID) + return nil } func (p *OrderProcessorImpl) CreatePaymentWithInventoryMovement(ctx context.Context, req *models.CreatePaymentRequest, order *entities.Order, totalPaid float64) (*entities.Payment, error) { diff --git a/internal/repository/earning_repository.go b/internal/repository/earning_repository.go index f583c9d..517f3dc 100644 --- a/internal/repository/earning_repository.go +++ b/internal/repository/earning_repository.go @@ -28,6 +28,8 @@ type EarningOrder struct { DiscountAmount float64 PaymentStatus string IsVoid bool + // Cumulative rupiah refunded so far. + RefundAmount float64 // Nil when the order has no customer, or the customer row is gone. CustomerIsDefault *bool CustomerIsActive *bool @@ -52,6 +54,11 @@ type EarningRepository interface { ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error) // ListEarnTransactions returns the EARN rows written for an order. ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error) + // ReversalRequested is how much has been asked back from an EARN row so far: what + // was taken plus any shortfall. + ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error) + // LotIDsCreatedBy returns the lots a credit created. + LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error) } type earningRepository struct { @@ -74,6 +81,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid DiscountAmount float64 PaymentStatus string IsVoid bool + RefundAmount float64 CustomerIsDefault *bool CustomerIsActive *bool } @@ -81,7 +89,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id, o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id, o.subtotal, COALESCE(o.discount_amount, 0) AS discount_amount, o.payment_status, - COALESCE(o.is_void, false) AS is_void, + COALESCE(o.is_void, false) AS is_void, COALESCE(o.refund_amount, 0) AS refund_amount, c.is_default AS customer_is_default, c.is_active AS customer_is_active FROM orders o LEFT JOIN outlets ou ON ou.id = o.outlet_id @@ -102,6 +110,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid DiscountAmount: row.DiscountAmount, PaymentStatus: row.PaymentStatus, IsVoid: row.IsVoid, + RefundAmount: row.RefundAmount, CustomerIsDefault: row.CustomerIsDefault, CustomerIsActive: row.CustomerIsActive, } @@ -191,3 +200,35 @@ func (r *earningRepository) ListEarnTransactions(ctx context.Context, orderID uu } return rows, nil } + +func (r *earningRepository) ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error) { + var total int64 + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT COALESCE(SUM(COALESCE((metadata->>'requested_amount')::bigint, -amount)), 0) + FROM wallet_transactions + WHERE reverses_transaction_id = ? AND type = ?`, + earnTransactionID, constants.WalletTxTypeEarnReversal).Scan(&total).Error + if err != nil { + return 0, fmt.Errorf("failed to sum earning reversals: %w", err) + } + return total, nil +} + +func (r *earningRepository) LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error) { + var ids []string + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table("wallet_lots"). + Where("source_transaction_id = ?", transactionID). + Order("expires_at NULLS LAST, created_at, id"). + Pluck("id::text", &ids).Error + if err != nil { + return nil, fmt.Errorf("failed to list lots of a credit: %w", err) + } + out := make([]uuid.UUID, 0, len(ids)) + for _, s := range ids { + if id, err := uuid.Parse(s); err == nil { + out = append(out, id) + } + } + return out, nil +} -- 2.54.0 From fc97c78300131c7edda583094f0b26f1a33b20c5 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 10:50:37 +0700 Subject: [PATCH 25/47] feat(loyalty): show what an order earned on the order and receipt Adds points_earned and coins_earned to the order response (docs/prd-point-coin.md F3, PC-205). The POS prints the receipt from this response, so the receipt gets them too. The values are the sums of the order's EARN rows, read in one query for a list of orders. They are filled for create, add items, update, detail and list, and are 0 for an order that earned nothing. UpdateOrder earns before building its response, so a payment completed there already shows the earning. A failure to read them is logged and leaves them at 0 rather than failing the order read. The self-order session listing reads orders directly from the repository and still shows 0. The two order hooks are merged into one OrderLoyalty interface (paid, refunded, earned by orders) with a single SetLoyalty. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 3 +- internal/contract/order_contract.go | 4 + internal/models/order.go | 3 + internal/processor/earning_processor.go | 14 +++ .../processor/earning_processor_db_test.go | 8 ++ internal/processor/order_paid_hook_test.go | 72 ++++++++++----- internal/processor/order_processor.go | 92 +++++++++++++------ internal/repository/earning_repository.go | 35 +++++++ internal/transformer/order_transformer.go | 2 + .../transformer/order_transformer_test.go | 14 +++ 10 files changed, 194 insertions(+), 53 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index f79dd26..7f9b42e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -398,8 +398,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor loyaltySettingsProcessor := processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager) // Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3) earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) - orderProcessor.SetOrderPaidHook(earningProcessor) - orderProcessor.SetOrderRefundedHook(earningProcessor) + orderProcessor.SetLoyalty(earningProcessor) return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), diff --git a/internal/contract/order_contract.go b/internal/contract/order_contract.go index 80ea44f..5926775 100644 --- a/internal/contract/order_contract.go +++ b/internal/contract/order_contract.go @@ -92,6 +92,10 @@ type OrderResponse struct { TotalPaid float64 `json:"total_paid"` PaymentCount int `json:"payment_count"` SplitType *string `json:"split_type,omitempty"` + // EnakPoint and EnakCoin the order earned, for the order screen and the receipt + // (docs/prd-point-coin.md F3). Zero when it earned nothing. + PointsEarned int64 `json:"points_earned"` + CoinsEarned int64 `json:"coins_earned"` } type OrderItemResponse struct { diff --git a/internal/models/order.go b/internal/models/order.go index d18e2a9..d15278f 100644 --- a/internal/models/order.go +++ b/internal/models/order.go @@ -182,6 +182,9 @@ type OrderResponse struct { TotalPaid float64 PaymentCount int SplitType *string + // What the order earned (docs/prd-point-coin.md F3), from its EARN rows. + PointsEarned int64 + CoinsEarned int64 } type OrderItemResponse struct { diff --git a/internal/processor/earning_processor.go b/internal/processor/earning_processor.go index 02eb649..042a50a 100644 --- a/internal/processor/earning_processor.go +++ b/internal/processor/earning_processor.go @@ -182,3 +182,17 @@ func earningDescription(order *repository.EarningOrder) string { } return truncateRunes(description, walletDescriptionLimit) } + +// EarnedByOrders returns what each order earned, as written in its EARN rows, for the +// order screen and the receipt (F3). Orders that earned nothing are absent. +func (p *EarningProcessor) EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error) { + rows, err := p.orders.EarnedByOrders(ctx, orderIDs) + if err != nil { + return nil, err + } + out := make(map[uuid.UUID]OrderEarned, len(rows)) + for id, byCurrency := range rows { + out[id] = OrderEarned{Points: byCurrency[constants.WalletCurrencyPoint], Coins: byCurrency[constants.WalletCurrencyCoin]} + } + return out, nil +} diff --git a/internal/processor/earning_processor_db_test.go b/internal/processor/earning_processor_db_test.go index ae6565f..0a58841 100644 --- a/internal/processor/earning_processor_db_test.go +++ b/internal/processor/earning_processor_db_test.go @@ -214,6 +214,14 @@ func TestEarningProcessor_AgainstPostgres(t *testing.T) { require.NoError(t, err) assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed)) + // What the order screen and receipt show is what the ledger has. + shown, err := earning.EarnedByOrders(ctx, []uuid.UUID{paid, split, missed, uuid.New()}) + require.NoError(t, err) + assert.Equal(t, OrderEarned{Points: 875, Coins: 3}, shown[paid]) + assert.Equal(t, OrderEarned{Points: 600, Coins: 2}, shown[split]) + assert.Equal(t, OrderEarned{Points: 400, Coins: 1}, shown[missed]) + assert.Len(t, shown, 3, "an order without earning is absent, so it shows 0") + balance := struct{ PointBalance, CoinBalance int64 }{} require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, regular).Scan(&balance).Error) assert.Equal(t, int64(875+250+600+400), balance.PointBalance) diff --git a/internal/processor/order_paid_hook_test.go b/internal/processor/order_paid_hook_test.go index 964ca49..bed9ef9 100644 --- a/internal/processor/order_paid_hook_test.go +++ b/internal/processor/order_paid_hook_test.go @@ -103,8 +103,10 @@ func (f splitFake) SplitByItem(_ context.Context, _ *models.SplitBillRequest, or } type orderPaidHookFake struct { - calls []uuid.UUID - ctxs []context.Context + calls []uuid.UUID + ctxs []context.Context + refunded []uuid.UUID + earned map[uuid.UUID]OrderEarned } func (h *orderPaidHookFake) OnOrderPaid(ctx context.Context, orderID uuid.UUID) { @@ -112,6 +114,14 @@ func (h *orderPaidHookFake) OnOrderPaid(ctx context.Context, orderID uuid.UUID) h.ctxs = append(h.ctxs, ctx) } +func (h *orderPaidHookFake) OnOrderRefunded(_ context.Context, orderID uuid.UUID) { + h.refunded = append(h.refunded, orderID) +} + +func (h *orderPaidHookFake) EarnedByOrders(context.Context, []uuid.UUID) (map[uuid.UUID]OrderEarned, error) { + return h.earned, nil +} + func newHookedOrderProcessor(split SplitBillProcessor) (*OrderProcessorImpl, *hookOrderRepo, *orderPaidHookFake) { hook := &orderPaidHookFake{} orders := &hookOrderRepo{ @@ -126,7 +136,7 @@ func newHookedOrderProcessor(split SplitBillProcessor) (*OrderProcessorImpl, *ho splitBillProcessor: split, txManager: repository.NewTxManager(nil), } - p.SetOrderPaidHook(hook) + p.SetLoyalty(hook) return p, orders, hook } @@ -175,7 +185,7 @@ func TestOrderPaidHook_SplitBillOnlyOnTheSettlingPayment(t *testing.T) { func TestOrderPaidHook_NoHookIsFine(t *testing.T) { p, orders, _ := newHookedOrderProcessor(nil) - p.SetOrderPaidHook(nil) + p.SetLoyalty(nil) _, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{}) assert.NoError(t, err) } @@ -204,43 +214,59 @@ func (r *hookPaymentRepo) RefundPayment(_ context.Context, id uuid.UUID, amount return nil } -type orderRefundedHookFake struct{ calls []uuid.UUID } - -func (h *orderRefundedHookFake) OnOrderRefunded(_ context.Context, orderID uuid.UUID) { - h.calls = append(h.calls, orderID) -} - func TestOrderRefundedHook(t *testing.T) { t.Run("void", func(t *testing.T) { - p, orders, _ := newHookedOrderProcessor(nil) - hook := &orderRefundedHookFake{} - p.SetOrderRefundedHook(hook) + p, orders, hook := newHookedOrderProcessor(nil) require.NoError(t, p.VoidOrder(context.Background(), &models.VoidOrderRequest{OrderID: orders.order.ID, Type: "ALL", Reason: "salah input"}, uuid.New())) - assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) + assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded) }) t.Run("refund order", func(t *testing.T) { - p, orders, _ := newHookedOrderProcessor(nil) + p, orders, hook := newHookedOrderProcessor(nil) orders.order.PaymentStatus = entities.PaymentStatusCompleted - hook := &orderRefundedHookFake{} - p.SetOrderRefundedHook(hook) amount := 25000.0 require.NoError(t, p.RefundOrder(context.Background(), orders.order.ID, &models.RefundOrderRequest{RefundAmount: &amount}, uuid.New())) - assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) + assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded) }) t.Run("refund payment", func(t *testing.T) { - p, orders, _ := newHookedOrderProcessor(nil) + p, orders, hook := newHookedOrderProcessor(nil) payments := p.paymentRepo.(*hookPaymentRepo) payment := &entities.Payment{ID: uuid.New(), OrderID: orders.order.ID, Amount: 50000, Status: entities.PaymentTransactionStatusCompleted} payments.created = append(payments.created, payment) - hook := &orderRefundedHookFake{} - p.SetOrderRefundedHook(hook) require.NoError(t, p.RefundPayment(context.Background(), payment.ID, 20000, "kembali", uuid.New())) - assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) + assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded) // A refund that fails does not run the hook. assert.Error(t, p.RefundPayment(context.Background(), payment.ID, 60000, "terlalu besar", uuid.New())) - assert.Len(t, hook.calls, 1) + assert.Len(t, hook.refunded, 1) }) } + +func (r *hookOrderRepo) List(context.Context, map[string]interface{}, int, int) ([]*entities.Order, int64, error) { + o := *r.order + other := entities.Order{ID: uuid.New()} + return []*entities.Order{&o, &other}, 2, nil +} + +func TestOrderResponsesCarryEarnings(t *testing.T) { + p, orders, hook := newHookedOrderProcessor(nil) + hook.earned = map[uuid.UUID]OrderEarned{orders.order.ID: {Points: 875, Coins: 3}} + + got, err := p.GetOrderByID(context.Background(), orders.order.ID) + require.NoError(t, err) + assert.Equal(t, int64(875), got.PointsEarned) + assert.Equal(t, int64(3), got.CoinsEarned) + + list, err := p.ListOrders(context.Background(), &models.ListOrdersRequest{Page: 1, Limit: 10}) + require.NoError(t, err) + require.Len(t, list.Orders, 2) + assert.Equal(t, int64(875), list.Orders[0].PointsEarned) + assert.Equal(t, int64(0), list.Orders[1].PointsEarned, "an order that earned nothing shows 0") + + // Without the loyalty program the fields stay at 0. + p.SetLoyalty(nil) + got, err = p.GetOrderByID(context.Background(), orders.order.ID) + require.NoError(t, err) + assert.Equal(t, int64(0), got.PointsEarned) +} diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 2c6426c..0bd9bc5 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -7,6 +7,7 @@ import ( "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/logger" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" @@ -109,40 +110,31 @@ type OrderProcessorImpl struct { ingredientRepo IngredientRepository inventoryMovementService InventoryMovementService productOutletPriceRepo repository.ProductOutletPriceRepository - orderPaidHook OrderPaidHook - orderRefundedHook OrderRefundedHook + loyalty OrderLoyalty } -// OrderPaidHook is told when an order has just become fully paid and the payment has -// committed. EarningProcessor is one (docs/prd-point-coin.md F3). -type OrderPaidHook interface { +// OrderLoyalty is what the order flow tells and asks the loyalty program +// (docs/prd-point-coin.md F3, F10). EarningProcessor is one. +type OrderLoyalty interface { + // OnOrderPaid runs once an order has just become fully paid and the payment has + // committed. OnOrderPaid(ctx context.Context, orderID uuid.UUID) -} - -// SetOrderPaidHook sets what runs when an order becomes fully paid. -func (p *OrderProcessorImpl) SetOrderPaidHook(hook OrderPaidHook) { - p.orderPaidHook = hook -} - -// OrderRefundedHook is told when an order has been voided or (partly) refunded and -// that has committed. EarningProcessor is one (docs/prd-point-coin.md F10). -type OrderRefundedHook interface { + // OnOrderRefunded runs once an order has been voided or (partly) refunded and that + // has committed. OnOrderRefunded(ctx context.Context, orderID uuid.UUID) + // EarnedByOrders returns what each order earned. + EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error) } -// SetOrderRefundedHook sets what runs when an order is voided or refunded. -func (p *OrderProcessorImpl) SetOrderRefundedHook(hook OrderRefundedHook) { - p.orderRefundedHook = hook +// OrderEarned is what one order earned, as written in its EARN rows. +type OrderEarned struct { + Points int64 + Coins int64 } -// onOrderRefunded is called at the end of VoidOrder, RefundOrder and RefundPayment, -// after their writes have committed, with the same guarantees as onOrderPaid: it can -// never block or fail the void or refund. -func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) { - if p.orderRefundedHook == nil { - return - } - p.orderRefundedHook.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID) +// SetLoyalty connects the order flow to the loyalty program. +func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) { + p.loyalty = loyalty } // onOrderPaid is the single place every path that completes an order's payment goes @@ -151,10 +143,45 @@ func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.U // transaction and from the request being cancelled, and anything it does cannot fail // the payment. func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID) { - if p.orderPaidHook == nil { + if p.loyalty == nil { return } - p.orderPaidHook.OnOrderPaid(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID) + p.loyalty.OnOrderPaid(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID) +} + +// onOrderRefunded is called at the end of VoidOrder, RefundOrder and RefundPayment, +// after their writes have committed, with the same guarantees as onOrderPaid: it can +// never block or fail the void or refund. +func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) { + if p.loyalty == nil { + return + } + p.loyalty.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID) +} + +// attachEarnings fills points_earned and coins_earned (F3). A failure is logged and +// leaves them at zero rather than failing the order read. +func (p *OrderProcessorImpl) attachEarnings(ctx context.Context, responses ...*models.OrderResponse) { + if p.loyalty == nil || len(responses) == 0 { + return + } + ids := make([]uuid.UUID, 0, len(responses)) + for _, r := range responses { + if r != nil { + ids = append(ids, r.ID) + } + } + earned, err := p.loyalty.EarnedByOrders(repository.DetachTransaction(ctx), ids) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::attachEarnings -> failed to read earnings") + return + } + for _, r := range responses { + if r != nil { + e := earned[r.ID] + r.PointsEarned, r.CoinsEarned = e.Points, e.Coins + } + } } func NewOrderProcessorImpl( @@ -343,6 +370,7 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create } response := mappers.OrderEntityToResponse(orderWithRelations) + p.attachEarnings(ctx, response) return response, nil } @@ -481,6 +509,7 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, } updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations) + p.attachEarnings(ctx, updatedOrderResponse) return &models.AddToOrderResponse{ OrderID: orderID, @@ -548,6 +577,7 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req } response := mappers.OrderEntityToResponse(orderWithRelations) + p.attachEarnings(ctx, response) return response, nil } @@ -558,6 +588,7 @@ func (p *OrderProcessorImpl) GetOrderByID(ctx context.Context, id uuid.UUID) (*m } response := mappers.OrderEntityToResponse(order) + p.attachEarnings(ctx, response) return response, nil } @@ -619,6 +650,11 @@ func (p *OrderProcessorImpl) ListOrders(ctx context.Context, req *models.ListOrd } } } + listed := make([]*models.OrderResponse, len(orderResponses)) + for i := range orderResponses { + listed[i] = &orderResponses[i] + } + p.attachEarnings(ctx, listed...) totalPages := int(total) / req.Limit if int(total)%req.Limit > 0 { diff --git a/internal/repository/earning_repository.go b/internal/repository/earning_repository.go index 517f3dc..7b31af3 100644 --- a/internal/repository/earning_repository.go +++ b/internal/repository/earning_repository.go @@ -59,6 +59,9 @@ type EarningRepository interface { ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error) // LotIDsCreatedBy returns the lots a credit created. LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error) + // EarnedByOrders sums the EARN rows of each order per currency, as + // map[order]map[currency]amount. Orders that earned nothing are absent. + EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]map[string]int64, error) } type earningRepository struct { @@ -232,3 +235,35 @@ func (r *earningRepository) LotIDsCreatedBy(ctx context.Context, transactionID u } return out, nil } + +func (r *earningRepository) EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]map[string]int64, error) { + earned := map[uuid.UUID]map[string]int64{} + if len(orderIDs) == 0 { + return earned, nil + } + var rows []struct { + OrderID string + Currency string + Amount int64 + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT reference_id::text AS order_id, currency, SUM(amount) AS amount + FROM wallet_transactions + WHERE reference_type = ? AND type = ? AND reference_id IN ? + GROUP BY reference_id, currency`, + constants.WalletRefTypeOrder, constants.WalletTxTypeEarn, orderIDs).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to sum earnings of orders: %w", err) + } + for _, row := range rows { + id, err := uuid.Parse(row.OrderID) + if err != nil { + continue + } + if earned[id] == nil { + earned[id] = map[string]int64{} + } + earned[id][row.Currency] = row.Amount + } + return earned, nil +} diff --git a/internal/transformer/order_transformer.go b/internal/transformer/order_transformer.go index b297cef..1c34626 100644 --- a/internal/transformer/order_transformer.go +++ b/internal/transformer/order_transformer.go @@ -161,6 +161,8 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse { TotalPaid: resp.TotalPaid, PaymentCount: resp.PaymentCount, SplitType: resp.SplitType, + PointsEarned: resp.PointsEarned, + CoinsEarned: resp.CoinsEarned, } } diff --git a/internal/transformer/order_transformer_test.go b/internal/transformer/order_transformer_test.go index c3067a0..5c55afd 100644 --- a/internal/transformer/order_transformer_test.go +++ b/internal/transformer/order_transformer_test.go @@ -1,11 +1,14 @@ package transformer import ( + "encoding/json" "testing" "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -46,3 +49,14 @@ func TestAddToOrderContractToModelCarriesWeight(t *testing.T) { require.NotNil(t, result.OrderItems[0].Weight) require.Equal(t, weight, *result.OrderItems[0].Weight) } + +func TestOrderModelToContractCarriesEarnings(t *testing.T) { + got := OrderModelToContract(&models.OrderResponse{PointsEarned: 875, CoinsEarned: 3}) + assert.Equal(t, int64(875), got.PointsEarned) + assert.Equal(t, int64(3), got.CoinsEarned) + + body, err := json.Marshal(OrderModelToContract(&models.OrderResponse{})) + assert.NoError(t, err) + assert.Contains(t, string(body), `"points_earned":0`, "present as 0 when nothing was earned") + assert.Contains(t, string(body), `"coins_earned":0`) +} -- 2.54.0 From 8370851ed25d85e77b003ca5c622c1d0f4f80368 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 11:20:29 +0700 Subject: [PATCH 26/47] feat(loyalty): customer PIN Adds the 6-digit customer PIN that approves every action moving EnakPoint or EnakCoin on the customer's request (docs/prd-point-coin.md K8, F11, Q16, Q17, PC-301). Migration 000093 adds the PIN columns to customers and the customer_security_events table. PIN data is read and written only through CustomerPinRepository, never the Customer entity, so the hash cannot reach a customer response. Only a bcrypt hash is stored. - /customer/pin: status, OTP (pin_setup, pin_reset), create, change, reset. The OTP must be for that purpose and sent to the customer's own number; the existing OTP validation checks neither. A new PIN is checked (6 digits, confirmed, not one digit, not a run up or down, not the birth date as DDMMYY or YYMMDD) before the OTP is spent. - Five wrong attempts in a row lock the PIN for 30 minutes; the counter is incremented in one statement so attempts at the same time all count, and a lock that ran out starts a new series. A locked PIN is refused even when right. The customer is told by WhatsApp, as there is no push channel to customers yet; only the attempt that reached the limit alerts. - A reset through OTP lifts the lock and holds outgoing transfers for 24 hours; paying and exchanging still work, and a held transfer costs no attempt. - VerifyPin(ctx, customer, pin, action) for the flows that follow, with PIN_NOT_SET, PIN_INVALID (attempts left), PIN_LOCKED and TRANSFER_BLOCKED (until when), which PinErrorResponse turns into distinct codes and statuses. - DELETE /marketing/customers/:id/pin (loyalty managers, reason required) and GET /marketing/customers/:id/security-events, scoped to the organization. Every PIN event is in the security log with IP and user agent. No message or binding error contains a PIN. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 7 + internal/constants/error.go | 24 +- internal/contract/customer_pin_contract.go | 28 ++ internal/handler/customer_pin_handler.go | 138 ++++++ internal/models/customer_pin.go | 39 ++ internal/processor/customer_pin_processor.go | 467 ++++++++++++++++++ .../customer_pin_processor_db_test.go | 257 ++++++++++ .../processor/customer_pin_processor_test.go | 36 ++ internal/processor/otp_processor.go | 13 + .../repository/customer_pin_repository.go | 220 +++++++++ internal/router/router.go | 12 +- internal/router/router_test.go | 7 + internal/service/customer_pin_service.go | 128 +++++ internal/service/customer_pin_service_test.go | 50 ++ migrations/000093_add_customer_pin.down.sql | 9 + migrations/000093_add_customer_pin.up.sql | 32 ++ 16 files changed, 1460 insertions(+), 7 deletions(-) create mode 100644 internal/contract/customer_pin_contract.go create mode 100644 internal/handler/customer_pin_handler.go create mode 100644 internal/models/customer_pin.go create mode 100644 internal/processor/customer_pin_processor.go create mode 100644 internal/processor/customer_pin_processor_db_test.go create mode 100644 internal/processor/customer_pin_processor_test.go create mode 100644 internal/repository/customer_pin_repository.go create mode 100644 internal/service/customer_pin_service.go create mode 100644 internal/service/customer_pin_service_test.go create mode 100644 migrations/000093_add_customer_pin.down.sql create mode 100644 migrations/000093_add_customer_pin.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index 7f9b42e..752520b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -156,6 +156,7 @@ func (a *App) Initialize(cfg *config.Config) error { services.walletAdminService, validators.walletValidator, services.loyaltySettingsService, + services.customerPinService, a.redisClient, ) @@ -386,12 +387,15 @@ type processors struct { walletAdminProcessor *processor.WalletAdminProcessor loyaltySettingsProcessor *processor.LoyaltySettingsProcessor earningProcessor *processor.EarningProcessor + customerPinProcessor *processor.CustomerPinProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { fileClient := client.NewFileClient(cfg.S3Config) fonnteClient := client.NewFonnteClient(cfg.GetFonnte()) otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo) + // Customer PIN (docs/prd-point-coin.md F11) + customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, otpProcessor) inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo) orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo) @@ -448,6 +452,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor walletProcessor: processor.NewWalletProcessor(repos.walletRepo), loyaltySettingsProcessor: loyaltySettingsProcessor, earningProcessor: earningProcessor, + customerPinProcessor: customerPinProcessor, walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), } } @@ -493,6 +498,7 @@ type services struct { cashAdvanceService *service.CashAdvanceServiceImpl walletAdminService *service.WalletAdminServiceImpl loyaltySettingsService *service.LoyaltySettingsServiceImpl + customerPinService *service.CustomerPinServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -576,6 +582,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor), walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor), loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor), + customerPinService: service.NewCustomerPinService(processors.customerPinProcessor), } } diff --git a/internal/constants/error.go b/internal/constants/error.go index e2c13a8..d0967fc 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -12,6 +12,12 @@ const ( ValidationErrorCode = "304" InvalidFieldErrorCode = "305" NotFoundErrorCode = "404" + // PIN outcomes the customer app tells apart (docs/prd-point-coin.md §9). + PinNotSetErrorCode = "PIN_NOT_SET" + PinInvalidErrorCode = "PIN_INVALID" + PinLockedErrorCode = "PIN_LOCKED" + TransferBlockedErrorCode = "TRANSFER_BLOCKED" + TooManyRequestsErrorCode = "429" ) const ( @@ -65,15 +71,21 @@ const ( CashAdvanceServiceEntity = "cash_advance_service" WalletServiceEntity = "wallet_service" LoyaltySettingsServiceEntity = "loyalty_settings_service" + CustomerPinServiceEntity = "customer_pin_service" ) var HttpErrorMap = map[string]int{ - InternalServerErrorCode: http.StatusInternalServerError, - MissingFieldErrorCode: http.StatusBadRequest, - MalformedFieldErrorCode: http.StatusBadRequest, - ValidationErrorCode: http.StatusBadRequest, - InvalidFieldErrorCode: http.StatusBadRequest, - NotFoundErrorCode: http.StatusNotFound, + InternalServerErrorCode: http.StatusInternalServerError, + MissingFieldErrorCode: http.StatusBadRequest, + MalformedFieldErrorCode: http.StatusBadRequest, + ValidationErrorCode: http.StatusBadRequest, + InvalidFieldErrorCode: http.StatusBadRequest, + NotFoundErrorCode: http.StatusNotFound, + PinNotSetErrorCode: http.StatusForbidden, + PinInvalidErrorCode: http.StatusBadRequest, + PinLockedErrorCode: http.StatusLocked, + TransferBlockedErrorCode: http.StatusForbidden, + TooManyRequestsErrorCode: http.StatusTooManyRequests, } // Error messages diff --git a/internal/contract/customer_pin_contract.go b/internal/contract/customer_pin_contract.go new file mode 100644 index 0000000..9b089eb --- /dev/null +++ b/internal/contract/customer_pin_contract.go @@ -0,0 +1,28 @@ +package contract + +// Requests of /customer/pin and /marketing/customers/:id/pin (docs/prd-point-coin.md +// F11). PINs are strings so a leading zero is kept. + +type RequestPinOtpRequest struct { + // pin_setup or pin_reset. + Purpose string `json:"purpose" binding:"required"` +} + +type CreateCustomerPinRequest struct { + OtpToken string `json:"otp_token" binding:"required"` + OtpCode string `json:"otp_code" binding:"required"` + Pin string `json:"pin" binding:"required"` + ConfirmPin string `json:"confirm_pin" binding:"required"` +} + +type ChangeCustomerPinRequest struct { + OldPin string `json:"old_pin" binding:"required"` + Pin string `json:"pin" binding:"required"` + ConfirmPin string `json:"confirm_pin" binding:"required"` +} + +type ResetCustomerPinRequest = CreateCustomerPinRequest + +type RemoveCustomerPinRequest struct { + Reason string `json:"reason" binding:"required"` +} diff --git a/internal/handler/customer_pin_handler.go b/internal/handler/customer_pin_handler.go new file mode 100644 index 0000000..65279f2 --- /dev/null +++ b/internal/handler/customer_pin_handler.go @@ -0,0 +1,138 @@ +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" +) + +// CustomerPinHandler serves /customer/pin and the dashboard's PIN endpoints +// (docs/prd-point-coin.md F11). Request bodies hold PINs, so nothing here logs a body, +// and binding errors are reported without the values sent. +type CustomerPinHandler struct { + pinService service.CustomerPinService +} + +func NewCustomerPinHandler(pinService service.CustomerPinService) *CustomerPinHandler { + return &CustomerPinHandler{pinService: pinService} +} + +func (h *CustomerPinHandler) Status(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerPinHandler::Status") + if !ok { + return + } + util.HandleResponse(c.Writer, c.Request, h.pinService.Status(c.Request.Context(), customerID), "CustomerPinHandler::Status") +} + +func (h *CustomerPinHandler) RequestOtp(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerPinHandler::RequestOtp") + if !ok { + return + } + var req contract.RequestPinOtpRequest + if !bindPinRequest(c, &req, "CustomerPinHandler::RequestOtp") { + return + } + util.HandleResponse(c.Writer, c.Request, h.pinService.RequestOtp(c.Request.Context(), customerID, &req), "CustomerPinHandler::RequestOtp") +} + +func (h *CustomerPinHandler) CreatePin(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerPinHandler::CreatePin") + if !ok { + return + } + var req contract.CreateCustomerPinRequest + if !bindPinRequest(c, &req, "CustomerPinHandler::CreatePin") { + return + } + util.HandleResponse(c.Writer, c.Request, h.pinService.CreatePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::CreatePin") +} + +func (h *CustomerPinHandler) ChangePin(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ChangePin") + if !ok { + return + } + var req contract.ChangeCustomerPinRequest + if !bindPinRequest(c, &req, "CustomerPinHandler::ChangePin") { + return + } + util.HandleResponse(c.Writer, c.Request, h.pinService.ChangePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ChangePin") +} + +func (h *CustomerPinHandler) ResetPin(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ResetPin") + if !ok { + return + } + var req contract.ResetCustomerPinRequest + if !bindPinRequest(c, &req, "CustomerPinHandler::ResetPin") { + return + } + util.HandleResponse(c.Writer, c.Request, h.pinService.ResetPin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ResetPin") +} + +// RemovePin is DELETE /marketing/customers/:id/pin. +func (h *CustomerPinHandler) RemovePin(c *gin.Context) { + customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::RemovePin") + if !ok { + return + } + var req contract.RemoveCustomerPinRequest + if !bindPinRequest(c, &req, "CustomerPinHandler::RemovePin") { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.pinService.RemovePin(ctx, appcontext.FromGinContext(ctx), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::RemovePin") +} + +// ListSecurityEvents is GET /marketing/customers/:id/security-events. +func (h *CustomerPinHandler) ListSecurityEvents(c *gin.Context) { + customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::ListSecurityEvents") + if !ok { + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.pinService.ListSecurityEvents(ctx, appcontext.FromGinContext(ctx), customerID, page, limit), "CustomerPinHandler::ListSecurityEvents") +} + +// bindPinRequest binds a JSON body. The error it reports names what is wrong, never the +// values, since those can be PINs. +func bindPinRequest(c *gin.Context, req interface{}, method string) bool { + if err := c.ShouldBindJSON(req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "invalid request body: required fields are missing or have the wrong type"), + }), method) + return false + } + return true +} + +// customerIDFromGin reads the customer set by CustomerAuthMiddleware. +func customerIDFromGin(c *gin.Context, method string) (uuid.UUID, bool) { + raw, _ := c.Get("customer_id") + s, _ := raw.(string) + id, err := uuid.Parse(s) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"), + }), method) + return uuid.Nil, false + } + return id, true +} + +func pinRequestInfo(c *gin.Context) models.CustomerPinRequestInfo { + return models.CustomerPinRequestInfo{IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent()} +} diff --git a/internal/models/customer_pin.go b/internal/models/customer_pin.go new file mode 100644 index 0000000..fe5477b --- /dev/null +++ b/internal/models/customer_pin.go @@ -0,0 +1,39 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// CustomerPinStatus is GET /customer/pin/status. +type CustomerPinStatus struct { + HasPin bool `json:"has_pin"` + LockedUntil *time.Time `json:"locked_until"` + TransferBlockedUntil *time.Time `json:"transfer_blocked_until"` +} + +// CustomerPinOtp is what POST /customer/pin/otp returns: the token to send back with +// the code the customer received. +type CustomerPinOtp struct { + Purpose string `json:"purpose"` + OtpToken string `json:"otp_token"` + ExpiresAt time.Time `json:"expires_at"` +} + +// CustomerSecurityEventView is one row of GET /marketing/customers/:id/security-events. +type CustomerSecurityEventView struct { + ID uuid.UUID `json:"id"` + Event string `json:"event"` + ActorUser *uuid.UUID `json:"actor_user,omitempty"` + Reason *string `json:"reason,omitempty"` + IPAddress *string `json:"ip_address,omitempty"` + UserAgent *string `json:"user_agent,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// CustomerPinRequestInfo is where a PIN request came from, for the security log. +type CustomerPinRequestInfo struct { + IPAddress string + UserAgent string +} diff --git a/internal/processor/customer_pin_processor.go b/internal/processor/customer_pin_processor.go new file mode 100644 index 0000000..447e9da --- /dev/null +++ b/internal/processor/customer_pin_processor.go @@ -0,0 +1,467 @@ +package processor + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// PIN rules (docs/prd-point-coin.md F11, Q16, Q17). +const ( + pinLength = 6 + pinMaxAttempts = 5 + pinLockDuration = 30 * time.Minute + pinTransferHold = 24 * time.Hour + pinSecurityReasonN = 255 + + PinOtpPurposeSetup = "pin_setup" + PinOtpPurposeReset = "pin_reset" +) + +// Security log events. +const ( + PinEventSet = "PIN_SET" + PinEventChanged = "PIN_CHANGED" + PinEventReset = "PIN_RESET" + PinEventFailed = "PIN_FAILED" + PinEventLocked = "PIN_LOCKED" + PinEventRemovedByAdmin = "PIN_REMOVED_BY_ADMIN" +) + +// What a PIN approves. Only a transfer is held after a reset. +type PinAction string + +const ( + PinActionPay PinAction = "PAY" + PinActionExchange PinAction = "EXCHANGE" + PinActionTransfer PinAction = "TRANSFER" +) + +// Codes of PinError, which the apps tell apart (docs/prd-point-coin.md §9). +const ( + PinErrNotSet = "PIN_NOT_SET" + PinErrInvalid = "PIN_INVALID" + PinErrLocked = "PIN_LOCKED" + PinErrTransferBlocked = "TRANSFER_BLOCKED" +) + +// PinError is why a PIN did not approve an action. +type PinError struct { + Code string + // Set for PIN_INVALID: attempts left before the PIN locks. + RemainingAttempts int + // Set for PIN_LOCKED and TRANSFER_BLOCKED. + Until *time.Time +} + +func (e *PinError) Error() string { + switch e.Code { + case PinErrNotSet: + return "PIN has not been set" + case PinErrInvalid: + return fmt.Sprintf("wrong PIN, %d attempts left", e.RemainingAttempts) + case PinErrLocked: + return fmt.Sprintf("PIN is locked until %s", e.Until.Format(time.RFC3339)) + case PinErrTransferBlocked: + return fmt.Sprintf("transfers are on hold after a PIN reset until %s", e.Until.Format(time.RFC3339)) + } + return e.Code +} + +var ( + // ErrInvalidPinInput wraps a PIN that is malformed, weak, or not confirmed. The + // message never contains the PIN. + ErrInvalidPinInput = errors.New("invalid PIN") + // ErrPinAlreadySet means a first PIN was requested for a customer who has one. + ErrPinAlreadySet = errors.New("PIN has already been set") + // ErrPinOtpInvalid means the OTP was wrong, expired, used, for another purpose, or + // sent to another number. + ErrPinOtpInvalid = errors.New("invalid or expired OTP") + // ErrPinOtpTooSoon means an OTP was requested again too quickly. + ErrPinOtpTooSoon = errors.New("an OTP was sent recently; wait before asking again") + // ErrPinNoPhone means the customer has no phone number to send an OTP to. + ErrPinNoPhone = errors.New("customer has no phone number") +) + +type pinOtpSender interface { + CanResendOtp(ctx context.Context, phoneNumber string, purpose string) (bool, int, error) + CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) + SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error + ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error) +} + +// pinAlerter tells a customer their PIN was locked. There is no push channel to +// customers yet, so the app sends it by WhatsApp. +type pinAlerter interface { + SendWhatsAppMessage(phoneNumber, message string) error +} + +// CustomerPinProcessor manages customer PINs (docs/prd-point-coin.md F11). Every flow +// that moves balance on the customer's request calls VerifyPin first (K8). +type CustomerPinProcessor struct { + repo repository.CustomerPinRepository + otp pinOtpSender + alerter pinAlerter + now func() time.Time + cost int +} + +func NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, alerter pinAlerter) *CustomerPinProcessor { + return &CustomerPinProcessor{repo: repo, otp: otp, alerter: alerter, now: time.Now, cost: bcrypt.DefaultCost} +} + +func (p *CustomerPinProcessor) Status(ctx context.Context, customerID uuid.UUID) (*models.CustomerPinStatus, error) { + state, err := p.repo.GetState(ctx, customerID) + if err != nil { + return nil, err + } + now := p.now() + status := &models.CustomerPinStatus{HasPin: state.PinHash != nil} + if state.LockedUntil != nil && state.LockedUntil.After(now) { + status.LockedUntil = state.LockedUntil + } + if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) { + status.TransferBlockedUntil = state.TransferBlockedUntil + } + return status, nil +} + +// RequestOtp sends an OTP to the customer's own phone number, for creating a first PIN +// (pin_setup) or resetting a forgotten one (pin_reset). +func (p *CustomerPinProcessor) RequestOtp(ctx context.Context, customerID uuid.UUID, purpose string) (*models.CustomerPinOtp, error) { + state, err := p.repo.GetState(ctx, customerID) + if err != nil { + return nil, err + } + switch purpose { + case PinOtpPurposeSetup: + if state.PinHash != nil { + return nil, ErrPinAlreadySet + } + case PinOtpPurposeReset: + if state.PinHash == nil { + return nil, &PinError{Code: PinErrNotSet} + } + default: + return nil, fmt.Errorf("%w: purpose must be %s or %s", ErrInvalidPinInput, PinOtpPurposeSetup, PinOtpPurposeReset) + } + if state.PhoneNumber == nil || *state.PhoneNumber == "" { + return nil, ErrPinNoPhone + } + + canSend, _, err := p.otp.CanResendOtp(ctx, *state.PhoneNumber, purpose) + if err != nil { + return nil, err + } + if !canSend { + return nil, ErrPinOtpTooSoon + } + session, err := p.otp.CreateOtpSession(ctx, *state.PhoneNumber, purpose) + if err != nil { + return nil, err + } + if err := p.otp.SendOtpViaWhatsApp(*state.PhoneNumber, session.Code, purpose); err != nil { + return nil, err + } + return &models.CustomerPinOtp{Purpose: purpose, OtpToken: session.Token, ExpiresAt: session.ExpiresAt}, nil +} + +// CreatePin sets a customer's first PIN, approved by an OTP to their phone so it is set +// by the owner of the number and not by whoever holds a logged-in phone. +func (p *CustomerPinProcessor) CreatePin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error { + state, err := p.repo.GetState(ctx, customerID) + if err != nil { + return err + } + if state.PinHash != nil { + return ErrPinAlreadySet + } + // Check the PIN before spending the OTP, so a weak PIN does not cost a new code. + if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil { + return err + } + if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeSetup); err != nil { + return err + } + hash, err := p.hash(pin) + if err != nil { + return err + } + if err := p.repo.SetPin(ctx, customerID, hash, nil); err != nil { + return err + } + p.logEvent(ctx, customerID, PinEventSet, nil, nil, info) + return nil +} + +// ChangePin replaces the PIN after checking the old one, which counts toward the lock +// like any other attempt. A transfer hold from an earlier reset stays. +func (p *CustomerPinProcessor) ChangePin(ctx context.Context, customerID uuid.UUID, oldPin, pin, confirmPin string, info models.CustomerPinRequestInfo) error { + state, err := p.repo.GetState(ctx, customerID) + if err != nil { + return err + } + if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil { + return err + } + if err := p.verify(ctx, state, oldPin, PinActionPay, info); err != nil { + return err + } + hash, err := p.hash(pin) + if err != nil { + return err + } + if err := p.repo.SetPin(ctx, customerID, hash, p.activeHold(state)); err != nil { + return err + } + p.logEvent(ctx, customerID, PinEventChanged, nil, nil, info) + return nil +} + +// ResetPin sets a new PIN for a customer who forgot theirs, approved by an OTP. It also +// lifts a lock, and holds outgoing transfers for 24 hours in case the phone number was +// taken over (Q16). +func (p *CustomerPinProcessor) ResetPin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error { + state, err := p.repo.GetState(ctx, customerID) + if err != nil { + return err + } + if state.PinHash == nil { + return &PinError{Code: PinErrNotSet} + } + if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil { + return err + } + if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeReset); err != nil { + return err + } + hash, err := p.hash(pin) + if err != nil { + return err + } + hold := p.now().Add(pinTransferHold) + if err := p.repo.SetPin(ctx, customerID, hash, &hold); err != nil { + return err + } + p.logEvent(ctx, customerID, PinEventReset, nil, nil, info) + return nil +} + +// VerifyPin checks the PIN before an action that moves balance. It returns a *PinError +// with the code the apps act on: PIN_NOT_SET, PIN_INVALID (with the attempts left), +// PIN_LOCKED or TRANSFER_BLOCKED (with until when). +func (p *CustomerPinProcessor) VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error { + state, err := p.repo.GetState(ctx, customerID) + if err != nil { + return err + } + return p.verify(ctx, state, pin, action, info) +} + +func (p *CustomerPinProcessor) verify(ctx context.Context, state *repository.CustomerPinState, pin string, action PinAction, info models.CustomerPinRequestInfo) error { + if state.PinHash == nil { + return &PinError{Code: PinErrNotSet} + } + now := p.now() + // A locked PIN is refused before it is compared, even when it is right. + if state.LockedUntil != nil && state.LockedUntil.After(now) { + until := *state.LockedUntil + return &PinError{Code: PinErrLocked, Until: &until} + } + // A held transfer is refused before the PIN is compared, so it costs no attempt. + if action == PinActionTransfer && state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) { + until := *state.TransferBlockedUntil + return &PinError{Code: PinErrTransferBlocked, Until: &until} + } + + if bcrypt.CompareHashAndPassword([]byte(*state.PinHash), []byte(pin)) != nil { + attempts, lockedUntil, err := p.repo.RecordFailure(ctx, state.CustomerID, pinMaxAttempts, now, now.Add(pinLockDuration)) + if err != nil { + return err + } + p.logEvent(ctx, state.CustomerID, PinEventFailed, nil, nil, info) + if lockedUntil != nil && lockedUntil.After(now) { + // Only the attempt that reached the limit logs the lock and tells the + // customer; attempts racing it just see the lock. + if attempts == pinMaxAttempts { + p.logEvent(ctx, state.CustomerID, PinEventLocked, nil, nil, info) + p.alertLocked(state, *lockedUntil) + } + return &PinError{Code: PinErrLocked, Until: lockedUntil} + } + return &PinError{Code: PinErrInvalid, RemainingAttempts: pinMaxAttempts - attempts} + } + if state.FailedAttempts > 0 || state.LockedUntil != nil { + if err := p.repo.ClearFailures(ctx, state.CustomerID); err != nil { + return err + } + } + return nil +} + +// RemovePinByAdmin deletes a customer's PIN, for example when they lost access to it, +// so they have to create a new one through OTP. Admins can never set or read a PIN. +func (p *CustomerPinProcessor) RemovePinByAdmin(ctx context.Context, organizationID, customerID, adminID uuid.UUID, reason string, info models.CustomerPinRequestInfo) error { + reason = strings.TrimSpace(reason) + if reason == "" { + return fmt.Errorf("%w: a reason is required", ErrInvalidPinInput) + } + if adminID == uuid.Nil { + return fmt.Errorf("%w: the admin is unknown", ErrInvalidPinInput) + } + state, err := p.repo.GetState(ctx, customerID) + if err != nil { + return err + } + if state.OrganizationID != organizationID { + return repository.ErrPinCustomerNotFound + } + if state.PinHash == nil { + return &PinError{Code: PinErrNotSet} + } + if err := p.repo.RemovePin(ctx, customerID); err != nil { + return err + } + reason = truncateRunes(reason, pinSecurityReasonN) + p.logEvent(ctx, customerID, PinEventRemovedByAdmin, &adminID, &reason, info) + return nil +} + +// ListEvents returns a page of a customer's PIN security log for the dashboard. +func (p *CustomerPinProcessor) ListEvents(ctx context.Context, organizationID, customerID uuid.UUID, page, limit int) (*models.PaginatedResponse[models.CustomerSecurityEventView], error) { + state, err := p.repo.GetState(ctx, customerID) + if err != nil { + return nil, err + } + if state.OrganizationID != organizationID { + return nil, repository.ErrPinCustomerNotFound + } + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 20 + } + rows, total, err := p.repo.ListEvents(ctx, customerID, (page-1)*limit, limit) + if err != nil { + return nil, err + } + events := make([]models.CustomerSecurityEventView, 0, len(rows)) + for _, e := range rows { + events = append(events, models.CustomerSecurityEventView{ + ID: e.ID, Event: e.Event, ActorUser: e.ActorUser, Reason: e.Reason, + IPAddress: e.IPAddress, UserAgent: e.UserAgent, CreatedAt: e.CreatedAt, + }) + } + return &models.PaginatedResponse[models.CustomerSecurityEventView]{ + Data: events, + Pagination: models.Pagination{ + Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit)), + }, + }, nil +} + +// checkOtp validates an OTP and that it was issued for this purpose to this customer's +// own phone number. Without those checks an OTP from the login flow, or one sent to +// another number, could approve a PIN change. +func (p *CustomerPinProcessor) checkOtp(ctx context.Context, state *repository.CustomerPinState, token, code, purpose string) error { + if token == "" || code == "" || state.PhoneNumber == nil { + return ErrPinOtpInvalid + } + session, err := p.otp.ValidateOtpSession(ctx, token, code) + if err != nil || session == nil { + return ErrPinOtpInvalid + } + if session.Purpose != purpose || session.PhoneNumber != *state.PhoneNumber { + return ErrPinOtpInvalid + } + return nil +} + +func (p *CustomerPinProcessor) hash(pin string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(pin), p.cost) + if err != nil { + return "", fmt.Errorf("failed to hash PIN: %w", err) + } + return string(hash), nil +} + +func (p *CustomerPinProcessor) activeHold(state *repository.CustomerPinState) *time.Time { + if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(p.now()) { + return state.TransferBlockedUntil + } + return nil +} + +// logEvent records a security event. The log is best effort: failing to write it must +// not undo what the customer just did, so a failure is logged instead. +func (p *CustomerPinProcessor) logEvent(ctx context.Context, customerID uuid.UUID, event string, actor *uuid.UUID, reason *string, info models.CustomerPinRequestInfo) { + e := repository.CustomerSecurityEvent{CustomerID: customerID, Event: event, ActorUser: actor, Reason: reason} + if info.IPAddress != "" { + ip := truncateRunes(info.IPAddress, 45) + e.IPAddress = &ip + } + if info.UserAgent != "" { + ua := truncateRunes(info.UserAgent, 255) + e.UserAgent = &ua + } + if err := p.repo.InsertEvent(ctx, e); err != nil { + logger.NonContext.Error(fmt.Sprintf("Could not record %s for customer %s", event, customerID), err) + } +} + +func (p *CustomerPinProcessor) alertLocked(state *repository.CustomerPinState, until time.Time) { + if p.alerter == nil || state.PhoneNumber == nil { + return + } + message := fmt.Sprintf("PIN EnakPoint kamu terkunci sampai %s karena salah dimasukkan %d kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.", + until.In(walletDisplayLocation).Format("02 Jan 2006 15:04 WIB"), pinMaxAttempts) + if err := p.alerter.SendWhatsAppMessage(*state.PhoneNumber, message); err != nil { + logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s their PIN is locked", state.CustomerID), err) + } +} + +// checkNewPin rejects a PIN that is not 6 digits, does not match its confirmation, or +// is easy to guess: one digit repeated, a run up or down, or the birth date as DDMMYY +// or YYMMDD. +func checkNewPin(pin, confirm string, birthDate *time.Time) error { + if len(pin) != pinLength { + return fmt.Errorf("%w: a PIN is %d digits", ErrInvalidPinInput, pinLength) + } + for _, r := range pin { + if r < '0' || r > '9' { + return fmt.Errorf("%w: a PIN is digits only", ErrInvalidPinInput) + } + } + if pin != confirm { + return fmt.Errorf("%w: the PIN and its confirmation differ", ErrInvalidPinInput) + } + same, up, down := true, true, true + for i := 1; i < len(pin); i++ { + d := int(pin[i]) - int(pin[i-1]) + same = same && d == 0 + up = up && d == 1 + down = down && d == -1 + } + if same || up || down { + return fmt.Errorf("%w: the PIN is too easy to guess", ErrInvalidPinInput) + } + if birthDate != nil { + for _, layout := range []string{"020106", "060102"} { + if pin == birthDate.Format(layout) { + return fmt.Errorf("%w: the PIN must not be your birth date", ErrInvalidPinInput) + } + } + } + return nil +} diff --git a/internal/processor/customer_pin_processor_db_test.go b/internal/processor/customer_pin_processor_db_test.go new file mode 100644 index 0000000..7167972 --- /dev/null +++ b/internal/processor/customer_pin_processor_db_test.go @@ -0,0 +1,257 @@ +package processor + +import ( + "context" + "errors" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// otpFake keeps OTP sessions in memory with the checks the real one makes. +type otpFake struct { + mu sync.Mutex + sessions map[string]*entities.OtpSession + sent []string +} + +func (f *otpFake) CanResendOtp(context.Context, string, string) (bool, int, error) { + return true, 0, nil +} + +func (f *otpFake) CreateOtpSession(_ context.Context, phone, purpose string) (*entities.OtpSession, error) { + f.mu.Lock() + defer f.mu.Unlock() + s := &entities.OtpSession{Token: uuid.NewString(), Code: "246810", PhoneNumber: phone, Purpose: purpose, ExpiresAt: time.Now().Add(5 * time.Minute)} + f.sessions[s.Token] = s + return s, nil +} + +func (f *otpFake) SendOtpViaWhatsApp(phone, code, purpose string) error { + f.sent = append(f.sent, purpose) + return nil +} + +func (f *otpFake) ValidateOtpSession(_ context.Context, token, code string) (*entities.OtpSession, error) { + f.mu.Lock() + defer f.mu.Unlock() + s := f.sessions[token] + if s == nil || s.IsUsed || s.Code != code { + return nil, errors.New("invalid OTP") + } + s.IsUsed = true + return s, nil +} + +// issue creates a session as if it had been sent, for any purpose and number. +func (f *otpFake) issue(phone, purpose string) *entities.OtpSession { + s, _ := f.CreateOtpSession(context.Background(), phone, purpose) + return s +} + +type alerterFake struct { + mu sync.Mutex + messages []string +} + +func (f *alerterFake) SendWhatsAppMessage(_ string, message string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.messages = append(f.messages, message) + return nil +} + +// Needs TEST_DATABASE_URL pointing at a migrated database; see +// internal/repository/wallet_repository_test.go. +func TestCustomerPin_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, otherOrg, customer, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New() + phone := "0812" + customer.String()[:8] + 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 (?, 'pin test', 'basic'), (?, 'other', 'basic')`, org, otherOrg) + exec(`INSERT INTO customers (id, organization_id, name, phone_number, birth_date) VALUES (?, ?, 'Budi', ?, '1990-03-14')`, customer, org, phone) + t.Cleanup(func() { + db.Exec(`DELETE FROM customer_security_events WHERE customer_id = ?`, customer) + db.Exec(`DELETE FROM customers WHERE id = ?`, customer) + db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg}) + }) + + otp := &otpFake{sessions: map[string]*entities.OtpSession{}} + alerts := &alerterFake{} + p := NewCustomerPinProcessor(repository.NewCustomerPinRepository(db), otp, alerts) + p.cost = bcrypt.MinCost + clock := time.Now() + var clockMu sync.Mutex + p.now = func() time.Time { clockMu.Lock(); defer clockMu.Unlock(); return clock } + advance := func(d time.Duration) { clockMu.Lock(); clock = clock.Add(d); clockMu.Unlock() } + info := models.CustomerPinRequestInfo{IPAddress: "10.0.0.7", UserAgent: "EnakApp/2.0"} + const pin, newPin, resetPin = "482913", "572039", "613408" + + pinErr := func(err error) *PinError { + t.Helper() + var pe *PinError + require.True(t, errors.As(err, &pe), "want a PinError, got %v", err) + for _, secret := range []string{pin, newPin, resetPin} { + assert.NotContains(t, err.Error(), secret, "an error must never contain a PIN") + } + return pe + } + events := func() []string { + t.Helper() + var out []string + require.NoError(t, db.Raw(`SELECT event FROM customer_security_events WHERE customer_id = ? ORDER BY created_at, id`, customer).Scan(&out).Error) + return out + } + + // No PIN yet: nothing can be approved. + status, err := p.Status(ctx, customer) + require.NoError(t, err) + assert.False(t, status.HasPin) + assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info)).Code) + + // Creating the first PIN takes an OTP sent to the customer's own number, for this + // purpose. + sent, err := p.RequestOtp(ctx, customer, PinOtpPurposeSetup) + require.NoError(t, err) + assert.Equal(t, []string{PinOtpPurposeSetup}, otp.sent) + + loginOtp := otp.issue(phone, "login") + assert.ErrorIs(t, p.CreatePin(ctx, customer, loginOtp.Token, loginOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP for another purpose") + strangerOtp := otp.issue("0899999999", PinOtpPurposeSetup) + assert.ErrorIs(t, p.CreatePin(ctx, customer, strangerOtp.Token, strangerOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP sent to another number") + assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "000000", pin, pin, info), ErrPinOtpInvalid, "a wrong code") + + // A weak PIN is refused before the OTP is used, so the same OTP still works after. + assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "123456", "123456", info), ErrInvalidPinInput) + assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "140390", "140390", info), ErrInvalidPinInput, "birth date") + require.NoError(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info)) + assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info), ErrPinAlreadySet) + + var stored string + require.NoError(t, db.Raw(`SELECT pin_hash FROM customers WHERE id = ?`, customer).Scan(&stored).Error) + assert.NotContains(t, stored, pin, "only a hash is stored") + assert.True(t, strings.HasPrefix(stored, "$2"), "bcrypt") + + require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info)) + + // Four wrong attempts count down; the fifth locks for 30 minutes. + for left := 4; left >= 1; left-- { + pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info)) + assert.Equal(t, PinErrInvalid, pe.Code) + assert.Equal(t, left, pe.RemainingAttempts) + } + pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info)) + assert.Equal(t, PinErrLocked, pe.Code) + assert.WithinDuration(t, clock.Add(30*time.Minute), *pe.Until, time.Second) + assert.Len(t, alerts.messages, 1, "the customer is told the PIN locked") + + // While locked even the right PIN is refused. + pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info)) + assert.Equal(t, PinErrLocked, pe.Code) + status, err = p.Status(ctx, customer) + require.NoError(t, err) + assert.NotNil(t, status.LockedUntil) + + // Once the lock runs out a wrong PIN starts a new series of five. + advance(31 * time.Minute) + pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info)) + assert.Equal(t, PinErrInvalid, pe.Code) + assert.Equal(t, 4, pe.RemainingAttempts) + // The right PIN resets the count. + require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info)) + pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info)) + assert.Equal(t, 4, pe.RemainingAttempts) + require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info)) + + // Wrong attempts made at once all count: none slips past the lock. + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { defer wg.Done(); _ = p.VerifyPin(ctx, customer, "000001", PinActionPay, info) }() + } + wg.Wait() + pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info)) + assert.Equal(t, PinErrLocked, pe.Code) + + // Resetting through OTP lifts the lock and holds transfers for 24 hours. + _, err = p.RequestOtp(ctx, customer, PinOtpPurposeReset) + require.NoError(t, err) + setupOtp := otp.issue(phone, PinOtpPurposeSetup) + assert.ErrorIs(t, p.ResetPin(ctx, customer, setupOtp.Token, setupOtp.Code, resetPin, resetPin, info), ErrPinOtpInvalid, "a setup OTP cannot reset") + resetOtp := otp.issue(phone, PinOtpPurposeReset) + require.NoError(t, p.ResetPin(ctx, customer, resetOtp.Token, resetOtp.Code, resetPin, resetPin, info)) + status, err = p.Status(ctx, customer) + require.NoError(t, err) + assert.Nil(t, status.LockedUntil, "the lock is lifted") + require.NotNil(t, status.TransferBlockedUntil) + assert.WithinDuration(t, clock.Add(24*time.Hour), *status.TransferBlockedUntil, time.Second) + + require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionPay, info), "paying still works") + require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionExchange, info), "exchanging still works") + pe = pinErr(p.VerifyPin(ctx, customer, resetPin, PinActionTransfer, info)) + assert.Equal(t, PinErrTransferBlocked, pe.Code) + var failed int + require.NoError(t, db.Raw(`SELECT pin_failed_attempts FROM customers WHERE id = ?`, customer).Scan(&failed).Error) + assert.Zero(t, failed, "a held transfer costs no attempt") + + // Changing the PIN needs the old one and keeps the transfer hold. + assert.Equal(t, PinErrInvalid, pinErr(p.ChangePin(ctx, customer, "000001", newPin, newPin, info)).Code) + require.NoError(t, p.ChangePin(ctx, customer, resetPin, newPin, newPin, info)) + require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionPay, info)) + assert.Equal(t, PinErrTransferBlocked, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info)).Code) + advance(25 * time.Hour) + require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info), "the hold ends after 24 hours") + + // An admin can remove the PIN, only in their own organization and with a reason. + assert.ErrorIs(t, p.RemovePinByAdmin(ctx, otherOrg, customer, admin, "hilang HP", info), repository.ErrPinCustomerNotFound) + assert.ErrorIs(t, p.RemovePinByAdmin(ctx, org, customer, admin, " ", info), ErrInvalidPinInput) + require.NoError(t, p.RemovePinByAdmin(ctx, org, customer, admin, "hilang HP", info)) + status, err = p.Status(ctx, customer) + require.NoError(t, err) + assert.False(t, status.HasPin) + assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionPay, info)).Code) + + // Every event is in the security log, with where it came from. + got := events() + for _, want := range []string{PinEventSet, PinEventFailed, PinEventLocked, PinEventReset, PinEventChanged, PinEventRemovedByAdmin} { + assert.Contains(t, got, want) + } + page, err := p.ListEvents(ctx, org, customer, 1, 100) + require.NoError(t, err) + assert.EqualValues(t, len(got), page.Pagination.Total) + removed := page.Data[0] + assert.Equal(t, PinEventRemovedByAdmin, removed.Event) + assert.Equal(t, &admin, removed.ActorUser) + assert.Equal(t, "hilang HP", *removed.Reason) + assert.Equal(t, "10.0.0.7", *removed.IPAddress) + _, err = p.ListEvents(ctx, otherOrg, customer, 1, 10) + assert.ErrorIs(t, err, repository.ErrPinCustomerNotFound) + + var locked int + require.NoError(t, db.Raw(`SELECT COUNT(*) FROM customer_security_events WHERE customer_id = ? AND event = ?`, customer, PinEventLocked).Scan(&locked).Error) + assert.Equal(t, locked, len(alerts.messages), "one alert per lock") +} diff --git a/internal/processor/customer_pin_processor_test.go b/internal/processor/customer_pin_processor_test.go new file mode 100644 index 0000000..b2d5ac7 --- /dev/null +++ b/internal/processor/customer_pin_processor_test.go @@ -0,0 +1,36 @@ +package processor + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCheckNewPin(t *testing.T) { + birth := time.Date(1990, 3, 14, 0, 0, 0, 0, time.UTC) + + for _, ok := range []string{"482913", "019283", "135790", "112233"} { + assert.NoError(t, checkNewPin(ok, ok, &birth), ok) + } + for name, c := range map[string][2]string{ + "too short": {"12345", "12345"}, + "too long": {"1234567", "1234567"}, + "not digits": {"12a456", "12a456"}, + "confirmation": {"482913", "482914"}, + "one digit": {"111111", "111111"}, + "zeros": {"000000", "000000"}, + "run up": {"123456", "123456"}, + "run up from 4": {"456789", "456789"}, + "run down": {"654321", "654321"}, + "run down from 9": {"987654", "987654"}, + "birth date DDMMYY": {"140390", "140390"}, + "birth date YYMMDD": {"900314", "900314"}, + } { + err := checkNewPin(c[0], c[1], &birth) + assert.ErrorIs(t, err, ErrInvalidPinInput, name) + assert.NotContains(t, err.Error(), c[0], "%s: the message must not echo the PIN", name) + } + // Without a birth date only the other rules apply. + assert.NoError(t, checkNewPin("140390", "140390", nil)) +} diff --git a/internal/processor/otp_processor.go b/internal/processor/otp_processor.go index f0616ed..b53d297 100644 --- a/internal/processor/otp_processor.go +++ b/internal/processor/otp_processor.go @@ -18,6 +18,8 @@ type OtpProcessor interface { CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) ResendOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error + // SendWhatsAppMessage sends any message to a customer number, formatted like OTPs. + SendWhatsAppMessage(phoneNumber string, message string) error ValidateOtpCode(code string) bool ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error) InvalidateOtpSession(ctx context.Context, token string) error @@ -133,6 +135,10 @@ func (p *otpProcessor) SendOtpViaWhatsApp(phoneNumber string, otpCode string, pu switch purpose { case "login": message = fmt.Sprintf("Kode OTP untuk login kamu adalah %s. Berlaku 5 menit.", otpCode) + case "pin_setup": + message = fmt.Sprintf("Kode OTP untuk membuat PIN EnakPoint kamu adalah %s. Berlaku 5 menit. Jangan berikan kode ini kepada siapa pun, termasuk kasir.", otpCode) + case "pin_reset": + message = fmt.Sprintf("Kode OTP untuk reset PIN EnakPoint kamu adalah %s. Berlaku 5 menit. Jangan berikan kode ini kepada siapa pun. Setelah reset, transfer ditahan 24 jam.", otpCode) case "registration": message = fmt.Sprintf("Kode OTP untuk registrasi kamu adalah %s. Berlaku 5 menit.", otpCode) default: @@ -236,3 +242,10 @@ func (p *otpProcessor) formatPhoneNumber(phoneNumber string) string { return digits } + +func (p *otpProcessor) SendWhatsAppMessage(phoneNumber string, message string) error { + if err := p.fonnteClient.SendWhatsAppMessage(p.formatPhoneNumber(phoneNumber), message); err != nil { + return fmt.Errorf("failed to send WhatsApp message: %w", err) + } + return nil +} diff --git a/internal/repository/customer_pin_repository.go b/internal/repository/customer_pin_repository.go new file mode 100644 index 0000000..6c38ab3 --- /dev/null +++ b/internal/repository/customer_pin_repository.go @@ -0,0 +1,220 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ErrPinCustomerNotFound means the customer does not exist. +var ErrPinCustomerNotFound = errors.New("pin: customer not found") + +// CustomerPinState is a customer's PIN and what guards it. It lives in the customers +// table but is read and written only here, never through the Customer entity, so the +// hash cannot end up in a customer response. +type CustomerPinState struct { + CustomerID uuid.UUID + OrganizationID uuid.UUID + PhoneNumber *string + BirthDate *time.Time + PinHash *string + PinSetAt *time.Time + FailedAttempts int + LockedUntil *time.Time + TransferBlockedUntil *time.Time +} + +// CustomerSecurityEvent is one row of the PIN security log. +type CustomerSecurityEvent struct { + ID uuid.UUID + CustomerID uuid.UUID + Event string + ActorUser *uuid.UUID + Reason *string + IPAddress *string + UserAgent *string + CreatedAt time.Time +} + +// CustomerPinRepository stores customer PINs and their security log +// (docs/prd-point-coin.md F11). +type CustomerPinRepository interface { + GetState(ctx context.Context, customerID uuid.UUID) (*CustomerPinState, error) + // SetPin stores a new PIN hash, clears the failure counter and any lock, and sets + // or clears the transfer hold. + SetPin(ctx context.Context, customerID uuid.UUID, hash string, transferBlockedUntil *time.Time) error + // RemovePin deletes the PIN, so the customer has to create a new one through OTP. + RemovePin(ctx context.Context, customerID uuid.UUID) error + // RecordFailure adds one wrong attempt in a single statement, so wrong attempts + // made at the same time all count. A lock that has already run out starts the + // count again. When the count reaches maxAttempts the PIN is locked until + // lockUntil. It returns the count and lock after the update. + RecordFailure(ctx context.Context, customerID uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) + ClearFailures(ctx context.Context, customerID uuid.UUID) error + + InsertEvent(ctx context.Context, event CustomerSecurityEvent) error + // ListEvents returns a page of the customer's log, newest first, and the total. + ListEvents(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerSecurityEvent, int64, error) +} + +type customerPinRepository struct { + db *gorm.DB +} + +func NewCustomerPinRepository(db *gorm.DB) CustomerPinRepository { + return &customerPinRepository{db: db} +} + +func (r *customerPinRepository) GetState(ctx context.Context, customerID uuid.UUID) (*CustomerPinState, error) { + var rows []struct { + CustomerID string + OrganizationID string + PhoneNumber *string + BirthDate *time.Time + PinHash *string + PinSetAt *time.Time + PinFailedAttempts int + PinLockedUntil *time.Time + TransferBlockedUntil *time.Time + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT id::text AS customer_id, organization_id::text AS organization_id, + COALESCE(phone_number, phone) AS phone_number, birth_date, + pin_hash, pin_set_at, pin_failed_attempts, pin_locked_until, transfer_blocked_until + FROM customers WHERE id = ? LIMIT 1`, customerID).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to read customer PIN: %w", err) + } + if len(rows) == 0 { + return nil, ErrPinCustomerNotFound + } + row := rows[0] + state := &CustomerPinState{ + PhoneNumber: row.PhoneNumber, + BirthDate: row.BirthDate, + PinHash: row.PinHash, + PinSetAt: row.PinSetAt, + FailedAttempts: row.PinFailedAttempts, + LockedUntil: row.PinLockedUntil, + TransferBlockedUntil: row.TransferBlockedUntil, + } + state.CustomerID, _ = uuid.Parse(row.CustomerID) + state.OrganizationID, _ = uuid.Parse(row.OrganizationID) + return state, nil +} + +func (r *customerPinRepository) SetPin(ctx context.Context, customerID uuid.UUID, hash string, transferBlockedUntil *time.Time) error { + result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` + UPDATE customers SET pin_hash = ?, pin_set_at = NOW(), pin_failed_attempts = 0, + pin_locked_until = NULL, transfer_blocked_until = ?, updated_at = NOW() + WHERE id = ?`, hash, transferBlockedUntil, customerID) + if result.Error != nil { + return fmt.Errorf("failed to store customer PIN: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrPinCustomerNotFound + } + return nil +} + +func (r *customerPinRepository) RemovePin(ctx context.Context, customerID uuid.UUID) error { + result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` + UPDATE customers SET pin_hash = NULL, pin_set_at = NULL, pin_failed_attempts = 0, + pin_locked_until = NULL, updated_at = NOW() + WHERE id = ?`, customerID) + if result.Error != nil { + return fmt.Errorf("failed to remove customer PIN: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrPinCustomerNotFound + } + return nil +} + +func (r *customerPinRepository) RecordFailure(ctx context.Context, customerID uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) { + var rows []struct { + PinFailedAttempts int + PinLockedUntil *time.Time + } + // When an earlier lock has run out, this attempt is the first of a new series. + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + UPDATE customers SET + pin_failed_attempts = CASE + WHEN pin_locked_until IS NOT NULL AND pin_locked_until <= @now THEN 1 + ELSE pin_failed_attempts + 1 END, + pin_locked_until = CASE + WHEN pin_locked_until IS NOT NULL AND pin_locked_until <= @now THEN NULL + WHEN pin_failed_attempts + 1 >= @max THEN @lock + ELSE pin_locked_until END + WHERE id = @id + RETURNING pin_failed_attempts, pin_locked_until`, + map[string]interface{}{"now": now, "max": maxAttempts, "lock": lockUntil, "id": customerID}). + Scan(&rows).Error + if err != nil { + return 0, nil, fmt.Errorf("failed to record a wrong PIN: %w", err) + } + if len(rows) == 0 { + return 0, nil, ErrPinCustomerNotFound + } + return rows[0].PinFailedAttempts, rows[0].PinLockedUntil, nil +} + +func (r *customerPinRepository) ClearFailures(ctx context.Context, customerID uuid.UUID) error { + return DBFromContext(ctx, r.db).WithContext(ctx).Exec(` + UPDATE customers SET pin_failed_attempts = 0, pin_locked_until = NULL + WHERE id = ? AND (pin_failed_attempts <> 0 OR pin_locked_until IS NOT NULL)`, customerID).Error +} + +func (r *customerPinRepository) InsertEvent(ctx context.Context, event CustomerSecurityEvent) error { + err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` + INSERT INTO customer_security_events (customer_id, event, actor_user, reason, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?, ?)`, + event.CustomerID, event.Event, event.ActorUser, event.Reason, event.IPAddress, event.UserAgent).Error + if err != nil { + return fmt.Errorf("failed to record security event: %w", err) + } + return nil +} + +func (r *customerPinRepository) ListEvents(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerSecurityEvent, int64, error) { + db := DBFromContext(ctx, r.db).WithContext(ctx) + var total int64 + if err := db.Table("customer_security_events").Where("customer_id = ?", customerID).Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count security events: %w", err) + } + var rows []struct { + ID string + CustomerID string + Event string + ActorUser *string + Reason *string + IPAddress *string + UserAgent *string + CreatedAt time.Time + } + err := db.Raw(` + SELECT id::text AS id, customer_id::text AS customer_id, event, actor_user::text AS actor_user, + reason, ip_address, user_agent, created_at + FROM customer_security_events WHERE customer_id = ? + ORDER BY created_at DESC, id DESC OFFSET ? LIMIT ?`, customerID, offset, limit).Scan(&rows).Error + if err != nil { + return nil, 0, fmt.Errorf("failed to list security events: %w", err) + } + events := make([]CustomerSecurityEvent, 0, len(rows)) + for _, row := range rows { + e := CustomerSecurityEvent{Event: row.Event, Reason: row.Reason, IPAddress: row.IPAddress, UserAgent: row.UserAgent, CreatedAt: row.CreatedAt} + e.ID, _ = uuid.Parse(row.ID) + e.CustomerID, _ = uuid.Parse(row.CustomerID) + if row.ActorUser != nil { + if id, err := uuid.Parse(*row.ActorUser); err == nil { + e.ActorUser = &id + } + } + events = append(events, e) + } + return events, total, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index 857e0fb..652a1f4 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -56,12 +56,13 @@ type Router struct { cashAdvanceHandler *handler.CashAdvanceHandler walletAdminHandler *handler.WalletAdminHandler loyaltySettingsHandler *handler.LoyaltySettingsHandler + customerPinHandler *handler.CustomerPinHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -109,6 +110,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer cashAdvanceHandler: handler.NewCashAdvanceHandler(cashAdvanceService, cashAdvanceValidator), walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator), loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService), + customerPinHandler: handler.NewCustomerPinHandler(customerPinService), redisClient: redisClient, } } @@ -162,6 +164,12 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens) customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions) + // PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11) + customer.GET("/pin/status", r.customerPinHandler.Status) + customer.POST("/pin/otp", r.customerPinHandler.RequestOtp) + customer.POST("/pin", r.customerPinHandler.CreatePin) + customer.PUT("/pin", r.customerPinHandler.ChangePin) + customer.POST("/pin/reset", r.customerPinHandler.ResetPin) customer.GET("/games", r.customerPointsHandler.GetCustomerGames) customer.GET("/ferris-wheel", r.customerPointsHandler.GetFerrisWheelGame) customer.POST("/spin", r.spinGameHandler.PlaySpinGame) @@ -624,6 +632,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { { marketingCustomers.GET("/:id/wallet", r.walletAdminHandler.GetCustomerWallet) marketingCustomers.POST("/:id/wallet/adjust", r.authMiddleware.RequireLoyaltyManager(), r.walletAdminHandler.AdjustCustomerWallet) + marketingCustomers.DELETE("/:id/pin", r.authMiddleware.RequireLoyaltyManager(), r.customerPinHandler.RemovePin) + marketingCustomers.GET("/:id/security-events", r.customerPinHandler.ListSecurityEvents) } campaignRules := gamification.Group("/campaign-rules") diff --git a/internal/router/router_test.go b/internal/router/router_test.go index ea80625..a267dfd 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -32,6 +32,13 @@ func TestAllRoutesRegister(t *testing.T) { "POST /api/v1/marketing/customers/:id/wallet/adjust", "GET /api/v1/outlets/:outlet_id/loyalty-settings", "PUT /api/v1/outlets/:outlet_id/loyalty-settings", + "GET /api/v1/customer/pin/status", + "POST /api/v1/customer/pin/otp", + "POST /api/v1/customer/pin", + "PUT /api/v1/customer/pin", + "POST /api/v1/customer/pin/reset", + "DELETE /api/v1/marketing/customers/:id/pin", + "GET /api/v1/marketing/customers/:id/security-events", } { assert.True(t, registered[want], want) } diff --git a/internal/service/customer_pin_service.go b/internal/service/customer_pin_service.go new file mode 100644 index 0000000..02c9020 --- /dev/null +++ b/internal/service/customer_pin_service.go @@ -0,0 +1,128 @@ +package service + +import ( + "context" + "errors" + + "github.com/google/uuid" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" +) + +// CustomerPinService serves the customer's PIN (docs/prd-point-coin.md F11) and the +// dashboard's view of it. +type CustomerPinService interface { + Status(ctx context.Context, customerID uuid.UUID) *contract.Response + RequestOtp(ctx context.Context, customerID uuid.UUID, req *contract.RequestPinOtpRequest) *contract.Response + CreatePin(ctx context.Context, customerID uuid.UUID, req *contract.CreateCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response + ChangePin(ctx context.Context, customerID uuid.UUID, req *contract.ChangeCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response + ResetPin(ctx context.Context, customerID uuid.UUID, req *contract.ResetCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response + + RemovePin(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.RemoveCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response + ListSecurityEvents(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, page, limit int) *contract.Response +} + +type CustomerPinServiceImpl struct { + pins *processor.CustomerPinProcessor +} + +func NewCustomerPinService(pins *processor.CustomerPinProcessor) *CustomerPinServiceImpl { + return &CustomerPinServiceImpl{pins: pins} +} + +func (s *CustomerPinServiceImpl) Status(ctx context.Context, customerID uuid.UUID) *contract.Response { + status, err := s.pins.Status(ctx, customerID) + if err != nil { + return PinErrorResponse(err) + } + return contract.BuildSuccessResponse(status) +} + +func (s *CustomerPinServiceImpl) RequestOtp(ctx context.Context, customerID uuid.UUID, req *contract.RequestPinOtpRequest) *contract.Response { + otp, err := s.pins.RequestOtp(ctx, customerID, req.Purpose) + if err != nil { + return PinErrorResponse(err) + } + return contract.BuildSuccessResponse(otp) +} + +func (s *CustomerPinServiceImpl) CreatePin(ctx context.Context, customerID uuid.UUID, req *contract.CreateCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response { + if err := s.pins.CreatePin(ctx, customerID, req.OtpToken, req.OtpCode, req.Pin, req.ConfirmPin, info); err != nil { + return PinErrorResponse(err) + } + return s.Status(ctx, customerID) +} + +func (s *CustomerPinServiceImpl) ChangePin(ctx context.Context, customerID uuid.UUID, req *contract.ChangeCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response { + if err := s.pins.ChangePin(ctx, customerID, req.OldPin, req.Pin, req.ConfirmPin, info); err != nil { + return PinErrorResponse(err) + } + return s.Status(ctx, customerID) +} + +func (s *CustomerPinServiceImpl) ResetPin(ctx context.Context, customerID uuid.UUID, req *contract.ResetCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response { + if err := s.pins.ResetPin(ctx, customerID, req.OtpToken, req.OtpCode, req.Pin, req.ConfirmPin, info); err != nil { + return PinErrorResponse(err) + } + return s.Status(ctx, customerID) +} + +func (s *CustomerPinServiceImpl) RemovePin(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.RemoveCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response { + if err := s.pins.RemovePinByAdmin(ctx, apctx.OrganizationID, customerID, apctx.UserID, req.Reason, info); err != nil { + return PinErrorResponse(err) + } + return contract.BuildSuccessResponse(map[string]interface{}{"message": "PIN removed; the customer has to create a new one"}) +} + +func (s *CustomerPinServiceImpl) ListSecurityEvents(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, page, limit int) *contract.Response { + events, err := s.pins.ListEvents(ctx, apctx.OrganizationID, customerID, page, limit) + if err != nil { + return PinErrorResponse(err) + } + return contract.BuildSuccessResponse(events) +} + +// PinErrorResponse turns an error from a PIN-guarded action into a response the apps +// can act on. A *processor.PinError keeps its code (PIN_NOT_SET, PIN_INVALID, +// PIN_LOCKED, TRANSFER_BLOCKED) and puts the attempts left or the time it lifts in the +// response data. Other errors map to a validation or server error. +func PinErrorResponse(err error) *contract.Response { + var pinErr *processor.PinError + if errors.As(err, &pinErr) { + data := map[string]interface{}{"code": pinErr.Code} + switch pinErr.Code { + case processor.PinErrInvalid: + data["remaining_attempts"] = pinErr.RemainingAttempts + case processor.PinErrLocked: + data["locked_until"] = pinErr.Until + case processor.PinErrTransferBlocked: + data["transfer_blocked_until"] = pinErr.Until + } + return &contract.Response{ + Success: false, + Data: data, + Errors: []*contract.ResponseError{contract.NewResponseError(pinErr.Code, constants.CustomerPinServiceEntity, pinErr.Error())}, + } + } + + code := constants.InternalServerErrorCode + switch { + case errors.Is(err, repository.ErrPinCustomerNotFound): + code = constants.NotFoundErrorCode + case errors.Is(err, processor.ErrPinOtpTooSoon): + code = constants.TooManyRequestsErrorCode + case errors.Is(err, processor.ErrInvalidPinInput), + errors.Is(err, processor.ErrPinAlreadySet), + errors.Is(err, processor.ErrPinOtpInvalid), + errors.Is(err, processor.ErrPinNoPhone): + code = constants.ValidationErrorCode + } + return contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(code, constants.CustomerPinServiceEntity, err.Error()), + }) +} diff --git a/internal/service/customer_pin_service_test.go b/internal/service/customer_pin_service_test.go new file mode 100644 index 0000000..d59a69f --- /dev/null +++ b/internal/service/customer_pin_service_test.go @@ -0,0 +1,50 @@ +package service + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" + "apskel-pos-be/internal/util" +) + +func TestPinErrorResponse(t *testing.T) { + until := time.Date(2026, 9, 30, 12, 30, 0, 0, time.UTC) + for name, c := range map[string]struct { + err error + code string + status int + data map[string]interface{} + }{ + "not set": {&processor.PinError{Code: processor.PinErrNotSet}, "PIN_NOT_SET", http.StatusForbidden, map[string]interface{}{"code": "PIN_NOT_SET"}}, + "invalid": {fmt.Errorf("pay: %w", &processor.PinError{Code: processor.PinErrInvalid, RemainingAttempts: 2}), "PIN_INVALID", http.StatusBadRequest, map[string]interface{}{"code": "PIN_INVALID", "remaining_attempts": 2}}, + "locked": {&processor.PinError{Code: processor.PinErrLocked, Until: &until}, "PIN_LOCKED", http.StatusLocked, map[string]interface{}{"code": "PIN_LOCKED", "locked_until": &until}}, + "transfer": {&processor.PinError{Code: processor.PinErrTransferBlocked, Until: &until}, "TRANSFER_BLOCKED", http.StatusForbidden, map[string]interface{}{"code": "TRANSFER_BLOCKED", "transfer_blocked_until": &until}}, + } { + resp := PinErrorResponse(c.err) + assert.False(t, resp.Success, name) + assert.Equal(t, c.code, resp.Errors[0].Code, name) + assert.Equal(t, c.status, util.MapErrorCodeToHttpStatus(resp.Errors[0].Code), name) + assert.Equal(t, c.data, resp.Data, name) + } + + for name, c := range map[string]struct { + err error + status int + }{ + "weak PIN": {fmt.Errorf("%w: too easy", processor.ErrInvalidPinInput), http.StatusBadRequest}, + "bad OTP": {processor.ErrPinOtpInvalid, http.StatusBadRequest}, + "already set": {processor.ErrPinAlreadySet, http.StatusBadRequest}, + "too soon": {processor.ErrPinOtpTooSoon, http.StatusTooManyRequests}, + "no customer": {repository.ErrPinCustomerNotFound, http.StatusNotFound}, + "anything else": {fmt.Errorf("db down"), http.StatusInternalServerError}, + } { + resp := PinErrorResponse(c.err) + assert.Equal(t, c.status, util.MapErrorCodeToHttpStatus(resp.Errors[0].Code), name) + } +} diff --git a/migrations/000093_add_customer_pin.down.sql b/migrations/000093_add_customer_pin.down.sql new file mode 100644 index 0000000..95edae3 --- /dev/null +++ b/migrations/000093_add_customer_pin.down.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS customer_security_events; + +ALTER TABLE customers + DROP CONSTRAINT IF EXISTS chk_customers_pin_failed_attempts, + DROP COLUMN IF EXISTS transfer_blocked_until, + DROP COLUMN IF EXISTS pin_locked_until, + DROP COLUMN IF EXISTS pin_failed_attempts, + DROP COLUMN IF EXISTS pin_set_at, + DROP COLUMN IF EXISTS pin_hash; diff --git a/migrations/000093_add_customer_pin.up.sql b/migrations/000093_add_customer_pin.up.sql new file mode 100644 index 0000000..12e722b --- /dev/null +++ b/migrations/000093_add_customer_pin.up.sql @@ -0,0 +1,32 @@ +-- Customer PIN (docs/prd-point-coin.md F11, K8). A 6-digit PIN, separate from the +-- login password, approves everything that moves EnakPoint or EnakCoin on the +-- customer's request. Only its bcrypt hash is stored. +ALTER TABLE customers + ADD COLUMN pin_hash VARCHAR(255), + ADD COLUMN pin_set_at TIMESTAMP WITH TIME ZONE, + -- Kept in the database, not a cache, so it cannot be dodged by waiting for a cache + -- to expire or by hitting another server (Q17). + ADD COLUMN pin_failed_attempts INT NOT NULL DEFAULT 0, + ADD COLUMN pin_locked_until TIMESTAMP WITH TIME ZONE, + -- Outgoing transfers are held for 24 hours after a PIN reset (Q16). + ADD COLUMN transfer_blocked_until TIMESTAMP WITH TIME ZONE, + ADD CONSTRAINT chk_customers_pin_failed_attempts CHECK (pin_failed_attempts >= 0); + +-- Security log of PIN events. Not a balance movement, so not in wallet_transactions. +CREATE TABLE customer_security_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, + -- PIN_SET, PIN_CHANGED, PIN_RESET, PIN_FAILED, PIN_LOCKED, PIN_REMOVED_BY_ADMIN + event VARCHAR(30) NOT NULL, + -- The admin, for PIN_REMOVED_BY_ADMIN. + actor_user UUID, + reason VARCHAR(255), + ip_address VARCHAR(45), + user_agent VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + CONSTRAINT chk_customer_security_events_admin CHECK ( + event <> 'PIN_REMOVED_BY_ADMIN' OR (actor_user IS NOT NULL AND reason IS NOT NULL)) +); + +CREATE INDEX idx_customer_security_events_customer_id_created_at ON customer_security_events(customer_id, created_at DESC); -- 2.54.0 From fe2f459b03f61bf10c2308ad982953e295bde08c Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 11:26:16 +0700 Subject: [PATCH 27/47] feat(loyalty): organization loyalty settings API Adds GET and PUT /marketing/loyalty-settings and GET /marketing/loyalty-settings/history (docs/prd-point-coin.md F2, PC-302). The settings are the point value, the exchange rate, transfer limits and the stored expiry settings. PUT merges the body like the outlet settings and is limited to loyalty managers. Every response carries the impact of the change on the balances in circulation: outstanding EnakPoint and EnakCoin, their rupiah value, and the coins exchanged into points, before and after. With ?dry_run=true nothing is saved and the response lists the keys that would change, for the warning shown before saving. Saving records each change in loyalty_setting_changes with who made it; history can be filtered to one outlet. Changing the value leaves what was already written alone. The diff behind saving and previewing is shared. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 2 +- internal/handler/loyalty_settings_db_test.go | 2 +- internal/handler/loyalty_settings_handler.go | 45 +++++ .../handler/loyalty_settings_org_db_test.go | 180 ++++++++++++++++++ internal/models/loyalty.go | 54 ++++++ internal/models/loyalty_test.go | 32 ++++ .../processor/loyalty_settings_processor.go | 68 +++++-- .../processor/wallet_query_processor_test.go | 4 + .../repository/wallet_query_repository.go | 13 ++ internal/router/router.go | 5 + internal/router/router_test.go | 3 + internal/service/loyalty_settings_service.go | 101 +++++++++- 12 files changed, 485 insertions(+), 24 deletions(-) create mode 100644 internal/handler/loyalty_settings_org_db_test.go create mode 100644 internal/models/loyalty_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 752520b..33a1c09 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -581,7 +581,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con expenseService: service.NewExpenseService(processors.expenseProcessor), cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor), walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor), - loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor), + loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo), customerPinService: service.NewCustomerPinService(processors.customerPinProcessor), } } diff --git a/internal/handler/loyalty_settings_db_test.go b/internal/handler/loyalty_settings_db_test.go index 72bf8d9..d776ba1 100644 --- a/internal/handler/loyalty_settings_db_test.go +++ b/internal/handler/loyalty_settings_db_test.go @@ -53,7 +53,7 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) { }) settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db)) - h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings)) + h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db))) auth := middleware.NewAuthMiddleware(nil) gin.SetMode(gin.TestMode) diff --git a/internal/handler/loyalty_settings_handler.go b/internal/handler/loyalty_settings_handler.go index dd91544..ea73cb9 100644 --- a/internal/handler/loyalty_settings_handler.go +++ b/internal/handler/loyalty_settings_handler.go @@ -2,6 +2,7 @@ package handler import ( "io" + "strconv" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -70,3 +71,47 @@ func parseUUIDParam(c *gin.Context, name, method string) (uuid.UUID, bool) { } return id, true } + +func (h *LoyaltySettingsHandler) GetOrganizationSettings(c *gin.Context) { + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.loyaltySettingsService.GetOrganizationSettings(ctx, appcontext.FromGinContext(ctx)), "LoyaltySettingsHandler::GetOrganizationSettings") +} + +// UpdateOrganizationSettings is PUT /marketing/loyalty-settings. With ?dry_run=true it +// saves nothing and returns what would change and what the balances in circulation +// would be worth, for the warning the dashboard shows before saving (F2). +func (h *LoyaltySettingsHandler) UpdateOrganizationSettings(c *gin.Context) { + ctx := c.Request.Context() + body, err := io.ReadAll(io.LimitReader(c.Request.Body, loyaltySettingsBodyLimit)) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "unable to read request body"), + }), "LoyaltySettingsHandler::UpdateOrganizationSettings") + return + } + response := h.loyaltySettingsService.UpdateOrganizationSettings(ctx, appcontext.FromGinContext(ctx), body, c.Query("dry_run") == "true") + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::UpdateOrganizationSettings -> service call failed") + } + util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::UpdateOrganizationSettings") +} + +// ListHistory is GET /marketing/loyalty-settings/history, optionally for one outlet +// (?outlet_id=). +func (h *LoyaltySettingsHandler) ListHistory(c *gin.Context) { + ctx := c.Request.Context() + var outletID *uuid.UUID + if raw := c.Query("outlet_id"); raw != "" { + id, err := uuid.Parse(raw) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet_id"), + }), "LoyaltySettingsHandler::ListHistory") + return + } + outletID = &id + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) + util.HandleResponse(c.Writer, c.Request, h.loyaltySettingsService.ListHistory(ctx, appcontext.FromGinContext(ctx), outletID, page, limit), "LoyaltySettingsHandler::ListHistory") +} diff --git a/internal/handler/loyalty_settings_org_db_test.go b/internal/handler/loyalty_settings_org_db_test.go new file mode 100644 index 0000000..6b455d5 --- /dev/null +++ b/internal/handler/loyalty_settings_org_db_test.go @@ -0,0 +1,180 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + "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/appcontext" + applogger "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/middleware" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" + "apskel-pos-be/internal/service" +) + +// Drives GET/PUT /marketing/loyalty-settings and its history over HTTP down to +// Postgres. Needs TEST_DATABASE_URL pointing at a migrated database; see +// internal/repository/wallet_repository_test.go. +func TestOrganizationLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + applogger.Setup("fatal", "json") + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + + org, admin, customer := uuid.New(), uuid.New(), uuid.New() + 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 (?, 'A', 'basic')`, org) + exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org) + t.Cleanup(func() { + db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer) + db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer) + db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer) + db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM customers WHERE id = ?`, customer) + db.Exec(`DELETE FROM organizations WHERE id = ?`, org) + }) + + // Balances in circulation: 50.000 EnakPoint and 105 EnakCoin. + txm := repository.NewTxManager(db) + wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) + for _, c := range []struct { + currency, ref string + amount int64 + }{{"POINT", "LEGACY_POINTS", 50000}, {"COIN", "LEGACY_TOKENS", 105}} { + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ + CustomerID: customer, Currency: c.currency, Type: "MIGRATION", Amount: c.amount, + ReferenceType: c.ref, ReferenceID: uuid.New(), Description: "Saldo awal", + Metadata: map[string]interface{}{"frozen": true}}}) + return err + })) + } + + settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) + h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db))) + auth := middleware.NewAuthMiddleware(nil) + gin.SetMode(gin.TestMode) + router := gin.New() + for prefix, role := range map[string]string{"/manager": "manager", "/purchasing": "purchasing"} { + role := role + g := router.Group(prefix, func(c *gin.Context) { + ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String()) + ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String()) + ctx = context.WithValue(ctx, appcontext.UserRoleKey, role) + c.Request = c.Request.WithContext(ctx) + }) + g.GET("/loyalty-settings", h.GetOrganizationSettings) + g.PUT("/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOrganizationSettings) + g.GET("/loyalty-settings/history", h.ListHistory) + } + call := func(method, path, body string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + var out map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String()) + return rec.Code, out + } + data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) } + + status, body := call(http.MethodGet, "/manager/loyalty-settings", "") + require.Equal(t, http.StatusOK, status, body) + got := data(body) + assert.EqualValues(t, 1, got["point_value"]) + assert.Equal(t, map[string]any{"coin_amount": float64(1), "point_amount": float64(1)}, got["exchange"]) + assert.Equal(t, true, got["transfer"].(map[string]any)["enabled"]) + assert.Equal(t, "MONTH", got["point_expiry"].(map[string]any)["unit"]) + impact := got["impact"].(map[string]any) + assert.EqualValues(t, 50000, impact["outstanding_points"]) + assert.EqualValues(t, impact["point_rupiah_before"], impact["point_rupiah_after"]) + + // A dry run shows what the balances in circulation would be worth, and saves nothing. + change := `{"point_value": 100, "exchange": {"coin_amount": 10, "point_amount": 1}}` + status, body = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", change) + require.Equal(t, http.StatusOK, status, body) + got = data(body) + assert.Equal(t, true, got["dry_run"]) + impact = got["impact"].(map[string]any) + assert.EqualValues(t, 50000, impact["point_rupiah_before"]) + assert.EqualValues(t, 5000000, impact["point_rupiah_after"]) + assert.EqualValues(t, 105, impact["coins_as_points_before"]) + assert.EqualValues(t, 10, impact["coins_as_points_after"]) + assert.Len(t, got["changes"], 2) + status, body = call(http.MethodGet, "/manager/loyalty-settings", "") + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 1, data(body)["point_value"], "a dry run saves nothing") + status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "") + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 0, data(body)["pagination"].(map[string]any)["total_count"]) + + // Saving records every change with who made it. + status, body = call(http.MethodPut, "/manager/loyalty-settings", change) + require.Equal(t, http.StatusOK, status, body) + assert.Equal(t, false, data(body)["dry_run"]) + assert.EqualValues(t, 100, data(body)["point_value"]) + status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "") + require.Equal(t, http.StatusOK, status, body) + history := data(body)["data"].([]any) + require.Len(t, history, 2) + var keys []string + for _, raw := range history { + row := raw.(map[string]any) + keys = append(keys, row["key"].(string)) + assert.Equal(t, admin.String(), row["changed_by"]) + assert.Nil(t, row["old_value"], "was on its default") + assert.Nil(t, row["outlet_id"]) + } + assert.ElementsMatch(t, []string{"loyalty.point.value", "loyalty.exchange.coin_amount"}, keys) + + // Nothing already written changes: balances and ledger rows keep their numbers. + var rows []struct { + Amount int64 + Metadata string + } + require.NoError(t, db.Raw(`SELECT amount, metadata::text AS metadata FROM wallet_transactions WHERE customer_id = ? ORDER BY currency`, customer).Scan(&rows).Error) + require.Len(t, rows, 2) + assert.Equal(t, int64(105), rows[0].Amount) + assert.Equal(t, int64(50000), rows[1].Amount) + for _, r := range rows { + assert.Contains(t, r.Metadata, `"frozen": true`) + } + + // Out of bounds, unknown fields and the wrong role are refused. + for name, bad := range map[string]string{ + "point value 0": `{"point_value": 0}`, + "exchange 0": `{"exchange": {"coin_amount": 0}}`, + "expiry unit YEAR": `{"coin_expiry": {"unit": "YEAR"}}`, + "unknown field": `{"point_valeu": 5}`, + } { + status, _ = call(http.MethodPut, "/manager/loyalty-settings", bad) + assert.Equal(t, http.StatusBadRequest, status, name) + status, _ = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", bad) + assert.Equal(t, http.StatusBadRequest, status, name+" (dry run)") + } + status, _ = call(http.MethodPut, "/purchasing/loyalty-settings", `{"point_value": 5}`) + assert.Equal(t, http.StatusForbidden, status) + status, body = call(http.MethodGet, "/manager/loyalty-settings", "") + require.Equal(t, http.StatusOK, status, body) + assert.EqualValues(t, 100, data(body)["point_value"]) +} diff --git a/internal/models/loyalty.go b/internal/models/loyalty.go index 09aaed6..1d168ba 100644 --- a/internal/models/loyalty.go +++ b/internal/models/loyalty.go @@ -103,3 +103,57 @@ func LoyaltyCashbackPercent(earnValue, pointValue, earnPerAmount int64) float64 } return math.Round(float64(earnValue)*float64(pointValue)*10000/float64(earnPerAmount)) / 100 } + +// OrganizationLoyaltySettingsView is GET and PUT /marketing/loyalty-settings. +type OrganizationLoyaltySettingsView struct { + OrganizationLoyaltySettings + // What the balances in circulation are worth, before and after the change. + Impact LoyaltySettingsImpact `json:"impact"` + // On PUT, the keys that changed; on a dry run, the keys that would. + Changes []LoyaltySettingChange `json:"changes"` + // True when nothing was saved. + DryRun bool `json:"dry_run"` +} + +// LoyaltySettingsImpact shows how a change of point value or exchange rate changes what +// the balances in circulation are worth (F2). Before and after are equal when neither +// changes. +type LoyaltySettingsImpact struct { + OutstandingPoints int64 `json:"outstanding_points"` + OutstandingCoins int64 `json:"outstanding_coins"` + + PointValueBefore int64 `json:"point_value_before"` + PointValueAfter int64 `json:"point_value_after"` + PointRupiahBefore int64 `json:"point_rupiah_before"` + PointRupiahAfter int64 `json:"point_rupiah_after"` + + // The coins in circulation exchanged at the rate, in EnakPoint and in rupiah. + CoinsAsPointsBefore int64 `json:"coins_as_points_before"` + CoinsAsPointsAfter int64 `json:"coins_as_points_after"` + CoinRupiahBefore int64 `json:"coin_rupiah_before"` + CoinRupiahAfter int64 `json:"coin_rupiah_after"` +} + +// NewLoyaltySettingsImpact computes the impact of moving from one organization setting +// to another on the balances in circulation. +func NewLoyaltySettingsImpact(points, coins int64, before, after OrganizationLoyaltySettings) LoyaltySettingsImpact { + asPoints := func(s OrganizationLoyaltySettings) int64 { + if s.Exchange.CoinAmount <= 0 { + return 0 + } + return coins * s.Exchange.PointAmount / s.Exchange.CoinAmount + } + impact := LoyaltySettingsImpact{ + OutstandingPoints: points, + OutstandingCoins: coins, + PointValueBefore: before.PointValue, + PointValueAfter: after.PointValue, + PointRupiahBefore: points * before.PointValue, + PointRupiahAfter: points * after.PointValue, + CoinsAsPointsBefore: asPoints(before), + CoinsAsPointsAfter: asPoints(after), + } + impact.CoinRupiahBefore = impact.CoinsAsPointsBefore * before.PointValue + impact.CoinRupiahAfter = impact.CoinsAsPointsAfter * after.PointValue + return impact +} diff --git a/internal/models/loyalty_test.go b/internal/models/loyalty_test.go new file mode 100644 index 0000000..2fc48e5 --- /dev/null +++ b/internal/models/loyalty_test.go @@ -0,0 +1,32 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoyaltyCashbackPercent(t *testing.T) { + assert.Equal(t, 1.0, LoyaltyCashbackPercent(1, 1, 100)) + assert.Equal(t, 0.1, LoyaltyCashbackPercent(1, 1, 1000)) + assert.Equal(t, 0.33, LoyaltyCashbackPercent(1, 1, 300), "rounded to two decimals") + assert.Equal(t, 0.67, LoyaltyCashbackPercent(2, 1, 300), "rounded, not cut") + assert.Equal(t, 0.0, LoyaltyCashbackPercent(1, 1, 0)) +} + +func TestNewLoyaltySettingsImpact(t *testing.T) { + before := OrganizationLoyaltySettings{PointValue: 1, Exchange: LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}} + after := OrganizationLoyaltySettings{PointValue: 100, Exchange: LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}} + + got := NewLoyaltySettingsImpact(50000, 105, before, after) + assert.Equal(t, LoyaltySettingsImpact{ + OutstandingPoints: 50000, OutstandingCoins: 105, + PointValueBefore: 1, PointValueAfter: 100, + PointRupiahBefore: 50000, PointRupiahAfter: 5000000, + CoinsAsPointsBefore: 105, CoinsAsPointsAfter: 10, // floor(105 / 10) + CoinRupiahBefore: 105, CoinRupiahAfter: 1000, + }, got) + + same := NewLoyaltySettingsImpact(10, 10, before, before) + assert.Equal(t, same.PointRupiahBefore, same.PointRupiahAfter) +} diff --git a/internal/processor/loyalty_settings_processor.go b/internal/processor/loyalty_settings_processor.go index 58c6aa7..4c67363 100644 --- a/internal/processor/loyalty_settings_processor.go +++ b/internal/processor/loyalty_settings_processor.go @@ -141,27 +141,11 @@ func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organ // on its default and not changed is not written, so the history only shows real // changes. current and next list the same keys in the same order. func (p *LoyaltySettingsProcessor) apply(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, changedBy uuid.UUID, raw map[string]string, current, next []loyaltyField, store func(key string, value *string) error) ([]models.LoyaltySettingChange, error) { - var rows []entities.LoyaltySettingChange - for i, field := range next { - newValue := field.format() - if equalOptionalStrings(current[i].format(), newValue) { - continue - } - var oldValue *string - if v, ok := raw[field.key]; ok { - oldValue = &v - } - if err := store(field.key, newValue); err != nil { + rows := diffLoyaltyFields(organizationID, outletID, changedBy, raw, current, next) + for _, row := range rows { + if err := store(row.Key, row.NewValue); err != nil { return nil, err } - rows = append(rows, entities.LoyaltySettingChange{ - OrganizationID: organizationID, - OutletID: outletID, - Key: field.key, - OldValue: oldValue, - NewValue: newValue, - ChangedBy: changedBy, - }) } if err := p.repo.InsertChanges(ctx, rows); err != nil { return nil, err @@ -410,3 +394,49 @@ func equalOptionalStrings(a, b *string) bool { } return *a == *b } + +// diffLoyaltyFields lists the keys whose effective value differs between current and +// next, with the stored old value (nil when the key was on its default). current and +// next list the same keys in the same order. +func diffLoyaltyFields(organizationID uuid.UUID, outletID *uuid.UUID, changedBy uuid.UUID, raw map[string]string, current, next []loyaltyField) []entities.LoyaltySettingChange { + var rows []entities.LoyaltySettingChange + for i, field := range next { + newValue := field.format() + if equalOptionalStrings(current[i].format(), newValue) { + continue + } + var oldValue *string + if v, ok := raw[field.key]; ok { + oldValue = &v + } + rows = append(rows, entities.LoyaltySettingChange{ + OrganizationID: organizationID, + OutletID: outletID, + Key: field.key, + OldValue: oldValue, + NewValue: newValue, + ChangedBy: changedBy, + }) + } + return rows +} + +// PreviewOrganization validates new organization settings and lists what saving them +// would change, without writing anything. +func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, organizationID uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) { + if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil { + return nil, err + } + raw, err := p.repo.GetOrganizationValues(ctx, organizationID) + if err != nil { + return nil, err + } + var current models.OrganizationLoyaltySettings + loadLoyaltyFields(organizationLoyaltyFields(¤t), raw, "organization "+organizationID.String()) + rows := diffLoyaltyFields(organizationID, nil, uuid.Nil, raw, organizationLoyaltyFields(¤t), organizationLoyaltyFields(&settings)) + changes := make([]models.LoyaltySettingChange, 0, len(rows)) + for _, row := range rows { + changes = append(changes, loyaltySettingChangeModel(row)) + } + return changes, nil +} diff --git a/internal/processor/wallet_query_processor_test.go b/internal/processor/wallet_query_processor_test.go index 1a4a9aa..d990a2b 100644 --- a/internal/processor/wallet_query_processor_test.go +++ b/internal/processor/wallet_query_processor_test.go @@ -239,3 +239,7 @@ func TestWalletQueryProcessor_RejectsBadQueries(t *testing.T) { models.ListCustomerWalletTransactionsQuery{From: "2026-06-01", To: "2026-06-01"}) assert.NoError(t, err) } + +func (f *walletQueryRepoFake) OrganizationOutstanding(context.Context, uuid.UUID) (int64, int64, error) { + return 0, 0, nil +} diff --git a/internal/repository/wallet_query_repository.go b/internal/repository/wallet_query_repository.go index f59b75c..0591693 100644 --- a/internal/repository/wallet_query_repository.go +++ b/internal/repository/wallet_query_repository.go @@ -49,6 +49,8 @@ type WalletQueryRepository interface { NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) // ListTransactions returns a page of the ledger, newest first, and the total count. ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) + // OrganizationOutstanding sums every wallet balance of an organization. + OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (points, coins int64, err error) // ListLotsBySourceTransactions returns the lots created by the given credits. ListLotsBySourceTransactions(ctx context.Context, transactionIDs []uuid.UUID) ([]entities.WalletLot, error) } @@ -168,3 +170,14 @@ func (r *walletQueryRepository) ListLotsBySourceTransactions(ctx context.Context } return lots, nil } + +func (r *walletQueryRepository) OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (int64, int64, error) { + var totals struct{ Points, Coins int64 } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT COALESCE(SUM(point_balance), 0) AS points, COALESCE(SUM(coin_balance), 0) AS coins + FROM customer_wallets WHERE organization_id = ?`, organizationID).Scan(&totals).Error + if err != nil { + return 0, 0, fmt.Errorf("failed to sum outstanding balances: %w", err) + } + return totals.Points, totals.Coins, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index 652a1f4..7070399 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -627,6 +627,11 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { } // Campaign Rules + // EnakPoint & EnakCoin settings of the organization (docs/prd-point-coin.md F2) + gamification.GET("/loyalty-settings", r.loyaltySettingsHandler.GetOrganizationSettings) + gamification.PUT("/loyalty-settings", r.authMiddleware.RequireLoyaltyManager(), r.loyaltySettingsHandler.UpdateOrganizationSettings) + gamification.GET("/loyalty-settings/history", r.loyaltySettingsHandler.ListHistory) + // EnakPoint & EnakCoin wallet of one customer (docs/prd-point-coin.md F7) marketingCustomers := gamification.Group("/customers") { diff --git a/internal/router/router_test.go b/internal/router/router_test.go index a267dfd..0049f32 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -32,6 +32,9 @@ func TestAllRoutesRegister(t *testing.T) { "POST /api/v1/marketing/customers/:id/wallet/adjust", "GET /api/v1/outlets/:outlet_id/loyalty-settings", "PUT /api/v1/outlets/:outlet_id/loyalty-settings", + "GET /api/v1/marketing/loyalty-settings", + "PUT /api/v1/marketing/loyalty-settings", + "GET /api/v1/marketing/loyalty-settings/history", "GET /api/v1/customer/pin/status", "POST /api/v1/customer/pin/otp", "POST /api/v1/customer/pin", diff --git a/internal/service/loyalty_settings_service.go b/internal/service/loyalty_settings_service.go index 1d6c67a..aa87f5d 100644 --- a/internal/service/loyalty_settings_service.go +++ b/internal/service/loyalty_settings_service.go @@ -24,14 +24,26 @@ type LoyaltySettingsService interface { // UpdateOutletSettings applies a JSON body on top of the current settings: fields // left out keep their value, and null clears an optional limit. UpdateOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID, body []byte) *contract.Response + + GetOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response + // UpdateOrganizationSettings applies a JSON body like UpdateOutletSettings. With + // dryRun it only validates and reports what would change and its impact. + UpdateOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo, body []byte, dryRun bool) *contract.Response + // ListHistory returns the settings history of the organization, or of one outlet. + ListHistory(ctx context.Context, apctx *appcontext.ContextInfo, outletID *uuid.UUID, page, limit int) *contract.Response +} + +type outstandingBalanceReader interface { + OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (points, coins int64, err error) } type LoyaltySettingsServiceImpl struct { - settings *processor.LoyaltySettingsProcessor + settings *processor.LoyaltySettingsProcessor + outstanding outstandingBalanceReader } -func NewLoyaltySettingsService(settings *processor.LoyaltySettingsProcessor) *LoyaltySettingsServiceImpl { - return &LoyaltySettingsServiceImpl{settings: settings} +func NewLoyaltySettingsService(settings *processor.LoyaltySettingsProcessor, outstanding outstandingBalanceReader) *LoyaltySettingsServiceImpl { + return &LoyaltySettingsServiceImpl{settings: settings, outstanding: outstanding} } func (s *LoyaltySettingsServiceImpl) GetOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID) *contract.Response { @@ -115,3 +127,86 @@ func loyaltyErrorResponse(err error) *contract.Response { contract.NewResponseError(code, constants.LoyaltySettingsServiceEntity, message), }) } + +func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response { + current, err := s.settings.Organization(ctx, apctx.OrganizationID) + if err != nil { + return loyaltyErrorResponse(err) + } + view, err := s.organizationView(ctx, apctx.OrganizationID, *current, *current, nil, false) + if err != nil { + return loyaltyErrorResponse(err) + } + return contract.BuildSuccessResponse(view) +} + +// organizationSettingsInput is what PUT accepts: the settings plus the read-only fields +// of the GET response, which are ignored. +type organizationSettingsInput struct { + *models.OrganizationLoyaltySettings + Impact json.RawMessage `json:"impact"` + Changes json.RawMessage `json:"changes"` + DryRun json.RawMessage `json:"dry_run"` +} + +func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo, body []byte, dryRun bool) *contract.Response { + current, err := s.settings.Organization(ctx, apctx.OrganizationID) + if err != nil { + return loyaltyErrorResponse(err) + } + next := *current + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&organizationSettingsInput{OrganizationLoyaltySettings: &next}); err != nil { + return contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.LoyaltySettingsServiceEntity, fmt.Sprintf("invalid request body: %v", err)), + }) + } + + var changes []models.LoyaltySettingChange + if dryRun { + changes, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next) + } else { + changes, err = s.settings.UpdateOrganization(ctx, apctx.OrganizationID, apctx.UserID, next) + } + if err != nil { + return loyaltyErrorResponse(err) + } + after := next + if !dryRun { + saved, err := s.settings.Organization(ctx, apctx.OrganizationID) + if err != nil { + return loyaltyErrorResponse(err) + } + after = *saved + } + view, err := s.organizationView(ctx, apctx.OrganizationID, *current, after, changes, dryRun) + if err != nil { + return loyaltyErrorResponse(err) + } + return contract.BuildSuccessResponse(view) +} + +func (s *LoyaltySettingsServiceImpl) ListHistory(ctx context.Context, apctx *appcontext.ContextInfo, outletID *uuid.UUID, page, limit int) *contract.Response { + history, err := s.settings.ListChanges(ctx, apctx.OrganizationID, outletID, page, limit) + if err != nil { + return loyaltyErrorResponse(err) + } + return contract.BuildSuccessResponse(history) +} + +func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, changes []models.LoyaltySettingChange, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) { + points, coins, err := s.outstanding.OrganizationOutstanding(ctx, organizationID) + if err != nil { + return nil, err + } + if changes == nil { + changes = []models.LoyaltySettingChange{} + } + return &models.OrganizationLoyaltySettingsView{ + OrganizationLoyaltySettings: after, + Impact: models.NewLoyaltySettingsImpact(points, coins, before, after), + Changes: changes, + DryRun: dryRun, + }, nil +} -- 2.54.0 From cf5332c2814058035ed0867972e30bdb14e4f9a5 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 11:33:25 +0700 Subject: [PATCH 28/47] feat(loyalty): EnakPoint payment method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the system payment method for paying with EnakPoint (docs/prd-point-coin.md F9, §8, §10.5, PC-303). Migration 000094 allows the point type, keeps one per organization with a partial unique index, creates it for every existing organization, and adds a trigger that creates it for new ones, as the walk-in customer is. It adds payments.points_used and point_value. Their CHECK is written so it can never be NULL: the PRD form, (both NULL) OR (both > 0), is NULL for points_used with a NULL point_value, which a CHECK lets through, so a payment could have lost the value a refund depends on. A test caught it. The API cannot create, delete or retype the EnakPoint method, nor turn another method into one; that answers 400. Renaming it is allowed. The method list takes the outlet from ?outlet_id= or the user's outlet and leaves EnakPoint out when that outlet does not accept it, filtered in the query so the count stays right. The organization-wide active list is unchanged. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 2 +- internal/contract/payment_method_contract.go | 14 ++- internal/entities/payment.go | 21 ++-- internal/handler/payment_method_handler.go | 10 ++ internal/mappers/payment_method_mapper.go | 1 + internal/models/payment_method.go | 12 +- .../processor/payment_method_processor.go | 39 +++++- .../processor/point_payment_method_db_test.go | 118 ++++++++++++++++++ .../repository/payment_method_repository.go | 2 + internal/service/payment_method_service.go | 17 ++- .../validator/payment_method_validator.go | 1 + .../000094_add_point_payment_method.down.sql | 16 +++ .../000094_add_point_payment_method.up.sql | 45 +++++++ 13 files changed, 274 insertions(+), 24 deletions(-) create mode 100644 internal/processor/point_payment_method_db_test.go create mode 100644 migrations/000094_add_point_payment_method.down.sql create mode 100644 migrations/000094_add_point_payment_method.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index 33a1c09..a002680 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -414,7 +414,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo), inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo), orderProcessor: orderProcessor, - paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo), + paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo, loyaltySettingsProcessor), fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient), customerProcessor: processor.NewCustomerProcessor(repos.customerRepo), analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo), diff --git a/internal/contract/payment_method_contract.go b/internal/contract/payment_method_contract.go index 9153b7a..e7e2155 100644 --- a/internal/contract/payment_method_contract.go +++ b/internal/contract/payment_method_contract.go @@ -18,7 +18,7 @@ type CreatePaymentMethodRequest struct { type UpdatePaymentMethodRequest struct { Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"` - Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"` + Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"` Processor *string `json:"processor,omitempty" validate:"omitempty,max=100"` Configuration map[string]interface{} `json:"configuration,omitempty"` IsActive *bool `json:"is_active,omitempty"` @@ -38,11 +38,13 @@ type PaymentMethodResponse struct { type ListPaymentMethodsRequest struct { OrganizationID *uuid.UUID `json:"organization_id,omitempty"` - Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"` - IsActive *bool `json:"is_active,omitempty"` - Search string `json:"search,omitempty"` - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` + // When set, EnakPoint is left out unless the outlet accepts it (F9). + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"` + IsActive *bool `json:"is_active,omitempty"` + Search string `json:"search,omitempty"` + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` } type ListPaymentMethodsResponse struct { diff --git a/internal/entities/payment.go b/internal/entities/payment.go index 1e7ea29..f1bf058 100644 --- a/internal/entities/payment.go +++ b/internal/entities/payment.go @@ -13,13 +13,16 @@ const ( PaymentMethodTypeCash PaymentMethodType = "cash" PaymentMethodTypeCard PaymentMethodType = "card" PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet" + // The system method for paying with EnakPoint (docs/prd-point-coin.md F9). One per + // organization; it cannot be created, deleted or retyped through the API. + PaymentMethodTypePoint PaymentMethodType = "point" ) type PaymentMethod struct { ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` Name string `gorm:"not null;size:100" json:"name" validate:"required,min=1,max=100"` - Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet"` + Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet point"` Processor *string `gorm:"size:100" json:"processor"` Configuration Metadata `gorm:"type:jsonb;default:'{}'" json:"configuration"` IsActive bool `gorm:"default:true" json:"is_active"` @@ -69,12 +72,16 @@ type Payment struct { SplitType *SplitType `gorm:"size:20" json:"split_type,omitempty"` SplitDescription *string `gorm:"size:255" json:"split_description,omitempty"` RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"` - RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"` - RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"` - RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"` - Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + // Set only for a payment with EnakPoint: how many were used, and the rupiah value of + // one then, frozen so a refund returns exactly what was used. + PointsUsed *int64 `json:"points_used,omitempty"` + PointValue *float64 `gorm:"type:decimal(10,2)" json:"point_value,omitempty"` + RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"` + RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"` + RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"` + Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"` PaymentMethod PaymentMethod `gorm:"foreignKey:PaymentMethodID" json:"payment_method,omitempty"` diff --git a/internal/handler/payment_method_handler.go b/internal/handler/payment_method_handler.go index 49a914a..290d0df 100644 --- a/internal/handler/payment_method_handler.go +++ b/internal/handler/payment_method_handler.go @@ -113,6 +113,16 @@ func (h *PaymentMethodHandler) ListPaymentMethods(c *gin.Context) { req.OrganizationID = &contextInfo.OrganizationID + // At the cashier, EnakPoint is listed only where the outlet accepts it (F9). + if outletStr := c.Query("outlet_id"); outletStr != "" { + if outletID, err := uuid.Parse(outletStr); err == nil { + req.OutletID = &outletID + } + } else if contextInfo.OutletID != uuid.Nil { + outletID := contextInfo.OutletID + req.OutletID = &outletID + } + if isActiveStr := c.Query("is_active"); isActiveStr != "" { if isActive, err := strconv.ParseBool(isActiveStr); err == nil { req.IsActive = &isActive diff --git a/internal/mappers/payment_method_mapper.go b/internal/mappers/payment_method_mapper.go index 651e721..526acac 100644 --- a/internal/mappers/payment_method_mapper.go +++ b/internal/mappers/payment_method_mapper.go @@ -134,6 +134,7 @@ func ListPaymentMethodsContractToModel(req *contract.ListPaymentMethodsRequest) return &models.ListPaymentMethodsRequest{ OrganizationID: req.OrganizationID, + OutletID: req.OutletID, Type: paymentMethodType, IsActive: req.IsActive, Search: req.Search, diff --git a/internal/models/payment_method.go b/internal/models/payment_method.go index 398586d..b7ea272 100644 --- a/internal/models/payment_method.go +++ b/internal/models/payment_method.go @@ -51,11 +51,13 @@ type PaymentMethodResponse struct { type ListPaymentMethodsRequest struct { OrganizationID *uuid.UUID - Type *constants.PaymentMethodType - IsActive *bool - Search string - Page int `validate:"min=1"` - Limit int `validate:"min=1,max=100"` + // When set, EnakPoint is left out unless the outlet accepts it (F9). + OutletID *uuid.UUID + Type *constants.PaymentMethodType + IsActive *bool + Search string + Page int `validate:"min=1"` + Limit int `validate:"min=1,max=100"` } type ListPaymentMethodsResponse struct { diff --git a/internal/processor/payment_method_processor.go b/internal/processor/payment_method_processor.go index 952ab86..bd0b240 100644 --- a/internal/processor/payment_method_processor.go +++ b/internal/processor/payment_method_processor.go @@ -1,7 +1,10 @@ package processor import ( + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" "context" + "errors" "fmt" "apskel-pos-be/internal/mappers" @@ -20,17 +23,26 @@ type PaymentMethodProcessor interface { GetActivePaymentMethodsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]models.PaymentMethodResponse, error) } +// ErrSystemPaymentMethod means an attempt to create, delete or retype the EnakPoint +// method, which the system owns (docs/prd-point-coin.md F9). +var ErrSystemPaymentMethod = errors.New("the EnakPoint payment method is managed by the system: it cannot be created, deleted or change type") + type PaymentMethodProcessorImpl struct { paymentMethodRepo repository.PaymentMethodRepository + outletSettings outletSettingsReader } -func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository) *PaymentMethodProcessorImpl { +func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository, outletSettings outletSettingsReader) *PaymentMethodProcessorImpl { return &PaymentMethodProcessorImpl{ paymentMethodRepo: paymentMethodRepo, + outletSettings: outletSettings, } } func (p *PaymentMethodProcessorImpl) CreatePaymentMethod(ctx context.Context, req *models.CreatePaymentMethodRequest) (*models.PaymentMethodResponse, error) { + if req.Type == constants.PaymentMethodTypePoint { + return nil, ErrSystemPaymentMethod + } exists, err := p.paymentMethodRepo.ExistsByName(ctx, req.OrganizationID, req.Name, nil) if err != nil { return nil, fmt.Errorf("failed to check payment method name uniqueness: %w", err) @@ -77,6 +89,17 @@ func (p *PaymentMethodProcessorImpl) ListPaymentMethods(ctx context.Context, req if req.Search != "" { filters["search"] = req.Search } + // At the cashier EnakPoint only shows where the outlet accepts it (F9). Filtered in + // the query so paging stays right. + if req.OutletID != nil && p.outletSettings != nil { + settings, err := p.outletSettings.Outlet(ctx, *req.OutletID) + if err != nil { + return nil, fmt.Errorf("failed to read outlet loyalty settings: %w", err) + } + if !settings.PointPayment.AcceptPayment { + filters["exclude_type"] = string(constants.PaymentMethodTypePoint) + } + } offset := (req.Page - 1) * req.Limit @@ -116,6 +139,15 @@ func (p *PaymentMethodProcessorImpl) UpdatePaymentMethod(ctx context.Context, id return nil, fmt.Errorf("payment method not found: %w", err) } + // The EnakPoint method keeps its type, and no other method can become one. + if req.Type != nil { + wasPoint := existingPaymentMethod.Type == entities.PaymentMethodTypePoint + isPoint := *req.Type == constants.PaymentMethodTypePoint + if wasPoint != isPoint { + return nil, ErrSystemPaymentMethod + } + } + // Check name uniqueness if name is being updated if req.Name != nil && *req.Name != existingPaymentMethod.Name { exists, err := p.paymentMethodRepo.ExistsByName(ctx, existingPaymentMethod.OrganizationID, *req.Name, &id) @@ -147,10 +179,13 @@ func (p *PaymentMethodProcessorImpl) UpdatePaymentMethod(ctx context.Context, id func (p *PaymentMethodProcessorImpl) DeletePaymentMethod(ctx context.Context, id uuid.UUID) error { // Check if payment method exists - _, err := p.paymentMethodRepo.GetByID(ctx, id) + existing, err := p.paymentMethodRepo.GetByID(ctx, id) if err != nil { return fmt.Errorf("payment method not found: %w", err) } + if existing.Type == entities.PaymentMethodTypePoint { + return ErrSystemPaymentMethod + } // TODO: Check if payment method is being used in any payments // For now, allow deletion diff --git a/internal/processor/point_payment_method_db_test.go b/internal/processor/point_payment_method_db_test.go new file mode 100644 index 0000000..eb912ed --- /dev/null +++ b/internal/processor/point_payment_method_db_test.go @@ -0,0 +1,118 @@ +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/models" + "apskel-pos-be/internal/repository" +) + +// Needs TEST_DATABASE_URL pointing at a migrated database; see +// internal/repository/wallet_repository_test.go. +func TestPointPaymentMethod_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, user, accepting, refusing := uuid.New(), uuid.New(), uuid.New(), uuid.New() + exec := func(q string, args ...any) error { return db.Exec(q, args...).Error } + require.NoError(t, exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'pm test', 'basic')`, org)) + require.NoError(t, exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Terima'), (?, ?, 'Tolak')`, accepting, org, refusing, org)) + t.Cleanup(func() { + db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM outlet_settings WHERE outlet_id IN ?`, []uuid.UUID{accepting, refusing}) + db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{accepting, refusing}) + db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM organizations WHERE id = ?`, org) + }) + + // A new organization gets exactly one EnakPoint method from the trigger. + var methods []struct { + ID string + Name string + Type string + } + require.NoError(t, db.Raw(`SELECT id::text AS id, name, type FROM payment_methods WHERE organization_id = ?`, org).Scan(&methods).Error) + require.Len(t, methods, 1) + assert.Equal(t, "EnakPoint", methods[0].Name) + assert.Equal(t, "point", methods[0].Type) + pointID := uuid.MustParse(methods[0].ID) + + // The database refuses a second one. + assert.Error(t, exec(`INSERT INTO payment_methods (organization_id, name, type) VALUES (?, 'EnakPoint 2', 'point')`, org)) + + txm := repository.NewTxManager(db) + settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) + s, err := settings.Outlet(ctx, accepting) + require.NoError(t, err) + s.PointPayment.AcceptPayment = true + _, err = settings.UpdateOutlet(ctx, org, accepting, user, *s) + require.NoError(t, err) + + p := NewPaymentMethodProcessorImpl(repository.NewPaymentMethodRepositoryImpl(db), settings) + cash, err := p.CreatePaymentMethod(ctx, &models.CreatePaymentMethodRequest{OrganizationID: org, Name: "Tunai", Type: constants.PaymentMethodTypeCash, IsActive: ptr(true)}) + require.NoError(t, err) + + // The API cannot make, retype or delete an EnakPoint method. + _, err = p.CreatePaymentMethod(ctx, &models.CreatePaymentMethodRequest{OrganizationID: org, Name: "Poin Lain", Type: constants.PaymentMethodTypePoint, IsActive: ptr(true)}) + assert.ErrorIs(t, err, ErrSystemPaymentMethod) + toCash := constants.PaymentMethodTypeCash + _, err = p.UpdatePaymentMethod(ctx, pointID, &models.UpdatePaymentMethodRequest{Type: &toCash}) + assert.ErrorIs(t, err, ErrSystemPaymentMethod) + toPoint := constants.PaymentMethodTypePoint + _, err = p.UpdatePaymentMethod(ctx, cash.ID, &models.UpdatePaymentMethodRequest{Type: &toPoint}) + assert.ErrorIs(t, err, ErrSystemPaymentMethod) + assert.ErrorIs(t, p.DeletePaymentMethod(ctx, pointID), ErrSystemPaymentMethod) + + // Renaming it is fine; its type stays. + name := "Bayar pakai EnakPoint" + renamed, err := p.UpdatePaymentMethod(ctx, pointID, &models.UpdatePaymentMethodRequest{Name: &name, Type: &toPoint}) + require.NoError(t, err) + assert.Equal(t, name, renamed.Name) + + // At the cashier it shows only where the outlet accepts EnakPoint. + types := func(outlet *uuid.UUID) []constants.PaymentMethodType { + t.Helper() + list, err := p.ListPaymentMethods(ctx, &models.ListPaymentMethodsRequest{OrganizationID: &org, OutletID: outlet, Page: 1, Limit: 50}) + require.NoError(t, err) + var out []constants.PaymentMethodType + for _, m := range list.PaymentMethods { + out = append(out, m.Type) + } + assert.Equal(t, len(out), list.TotalCount, "the count matches what is listed") + return out + } + assert.ElementsMatch(t, []constants.PaymentMethodType{"cash", "point"}, types(&accepting)) + assert.ElementsMatch(t, []constants.PaymentMethodType{"cash"}, types(&refusing)) + assert.ElementsMatch(t, []constants.PaymentMethodType{"cash", "point"}, types(nil), "the dashboard, without an outlet, sees it") + + // A payment either records both points_used and point_value, or neither. + var orderID uuid.UUID + require.NoError(t, exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'K', ?, 'x', 'cashier')`, user, org, user.String()+"@t")) + orderID = uuid.New() + require.NoError(t, exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, order_number, order_type, subtotal, tax_amount, total_amount) + VALUES (?, ?, ?, ?, ?, 'dine_in', 1000, 0, 1000)`, orderID, org, accepting, user, "PM-"+orderID.String()[:8])) + t.Cleanup(func() { + db.Exec(`DELETE FROM payments WHERE order_id = ?`, orderID) + db.Exec(`DELETE FROM orders WHERE id = ?`, orderID) + db.Exec(`DELETE FROM users WHERE id = ?`, user) + }) + assert.Error(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used) VALUES (?, ?, 1000, 1000)`, orderID, pointID)) + assert.Error(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used, point_value) VALUES (?, ?, 1000, 0, 1)`, orderID, pointID)) + assert.NoError(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used, point_value) VALUES (?, ?, 1000, 1000, 1)`, orderID, pointID)) + assert.NoError(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount) VALUES (?, ?, 1000)`, orderID, cash.ID)) +} diff --git a/internal/repository/payment_method_repository.go b/internal/repository/payment_method_repository.go index 41977b7..7a9cd18 100644 --- a/internal/repository/payment_method_repository.go +++ b/internal/repository/payment_method_repository.go @@ -75,6 +75,8 @@ func (r *PaymentMethodRepositoryImpl) List(ctx context.Context, filters map[stri case "search": searchValue := "%" + value.(string) + "%" query = query.Where("name ILIKE ? OR processor ILIKE ?", searchValue, searchValue) + case "exclude_type": + query = query.Where("type <> ?", value) default: query = query.Where(key+" = ?", value) } diff --git a/internal/service/payment_method_service.go b/internal/service/payment_method_service.go index d0ecd7d..fa1198f 100644 --- a/internal/service/payment_method_service.go +++ b/internal/service/payment_method_service.go @@ -2,8 +2,10 @@ package service import ( "context" + "errors" "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/processor" @@ -39,7 +41,7 @@ func (s *PaymentMethodServiceImpl) CreatePaymentMethod(ctx context.Context, cont response, err := s.paymentMethodProcessor.CreatePaymentMethod(ctx, modelReq) if err != nil { return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError("PAYMENT_METHOD_CREATE_ERROR", "payment_method", err.Error()), + contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_CREATE_ERROR"), "payment_method", err.Error()), }) } @@ -84,7 +86,7 @@ func (s *PaymentMethodServiceImpl) UpdatePaymentMethod(ctx context.Context, id u response, err := s.paymentMethodProcessor.UpdatePaymentMethod(ctx, id, modelReq) if err != nil { return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError("PAYMENT_METHOD_UPDATE_ERROR", "payment_method", err.Error()), + contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_UPDATE_ERROR"), "payment_method", err.Error()), }) } @@ -97,7 +99,7 @@ func (s *PaymentMethodServiceImpl) DeletePaymentMethod(ctx context.Context, id u err := s.paymentMethodProcessor.DeletePaymentMethod(ctx, id) if err != nil { return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError("PAYMENT_METHOD_DELETE_ERROR", "payment_method", err.Error()), + contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_DELETE_ERROR"), "payment_method", err.Error()), }) } @@ -123,3 +125,12 @@ func (s *PaymentMethodServiceImpl) GetActivePaymentMethodsByOrganization(ctx con return contract.BuildSuccessResponse(contractResponses) } + +// paymentMethodErrorCode answers a request to create, delete or retype the EnakPoint +// method as a bad request instead of a server error. +func paymentMethodErrorCode(err error, fallback string) string { + if errors.Is(err, processor.ErrSystemPaymentMethod) { + return constants.ValidationErrorCode + } + return fallback +} diff --git a/internal/validator/payment_method_validator.go b/internal/validator/payment_method_validator.go index 8173abf..62d70e3 100644 --- a/internal/validator/payment_method_validator.go +++ b/internal/validator/payment_method_validator.go @@ -95,6 +95,7 @@ func (v *PaymentMethodValidatorImpl) isValidPaymentMethodType(paymentMethodType string(constants.PaymentMethodTypeDigitalWallet), string(constants.PaymentMethodTypeQR), string(constants.PaymentMethodTypeEDC), + string(constants.PaymentMethodTypePoint), } for _, validType := range validTypes { diff --git a/migrations/000094_add_point_payment_method.down.sql b/migrations/000094_add_point_payment_method.down.sql new file mode 100644 index 0000000..dd86218 --- /dev/null +++ b/migrations/000094_add_point_payment_method.down.sql @@ -0,0 +1,16 @@ +ALTER TABLE payments + DROP CONSTRAINT IF EXISTS chk_payments_point_pair, + DROP COLUMN IF EXISTS point_value, + DROP COLUMN IF EXISTS points_used; + +DROP TRIGGER IF EXISTS trigger_create_point_payment_method ON organizations; +DROP FUNCTION IF EXISTS create_point_payment_method(); + +-- Fails if an EnakPoint method has been used by a payment, which is the point: those +-- payments would lose their method. +DELETE FROM payment_methods WHERE type = 'point'; +DROP INDEX IF EXISTS uq_payment_methods_point_per_organization; + +ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check; +ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check + CHECK (type IN ('cash', 'card', 'digital_wallet')); diff --git a/migrations/000094_add_point_payment_method.up.sql b/migrations/000094_add_point_payment_method.up.sql new file mode 100644 index 0000000..e39899e --- /dev/null +++ b/migrations/000094_add_point_payment_method.up.sql @@ -0,0 +1,45 @@ +-- Paying with EnakPoint (docs/prd-point-coin.md F9, §8, §10.5). + +-- A new payment method type. Every organization has exactly one method of it, made by +-- the system, which cannot be deleted or change type. +ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check; +ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check + CHECK (type IN ('cash', 'card', 'digital_wallet', 'point')); + +CREATE UNIQUE INDEX uq_payment_methods_point_per_organization ON payment_methods(organization_id) + WHERE type = 'point'; + +INSERT INTO payment_methods (organization_id, name, type, is_active) +SELECT id, 'EnakPoint', 'point', TRUE FROM organizations +ON CONFLICT (organization_id) WHERE type = 'point' DO NOTHING; + +-- New organizations get theirs the same way they get their walk-in customer, whatever +-- code path creates them. +CREATE OR REPLACE FUNCTION create_point_payment_method() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO payment_methods (organization_id, name, type, is_active) + VALUES (NEW.id, 'EnakPoint', 'point', TRUE) + ON CONFLICT (organization_id) WHERE type = 'point' DO NOTHING; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_create_point_payment_method + AFTER INSERT ON organizations + FOR EACH ROW + EXECUTE FUNCTION create_point_payment_method(); + +-- A payment made with EnakPoint records how many were used and the rupiah value of one +-- at that moment. The value is frozen so a refund returns exactly the EnakPoint used, +-- whatever the value is by then. +-- +-- Written so it never evaluates to NULL: the form in the PRD, (both NULL) OR (both +-- > 0), is NULL for points_used = 1000 with point_value NULL, and a CHECK only rejects +-- FALSE, so a payment could lose its frozen value. +ALTER TABLE payments + ADD COLUMN points_used BIGINT, + ADD COLUMN point_value DECIMAL(10,2), + ADD CONSTRAINT chk_payments_point_pair CHECK ( + (points_used IS NULL) = (point_value IS NULL) + AND (points_used IS NULL OR (points_used > 0 AND point_value > 0))); -- 2.54.0 From 0c4dd725830c612a7e9da116f5c40012c4075161 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 11:37:20 +0700 Subject: [PATCH 29/47] feat(loyalty): one-time EnakPoint payment code Adds POST /customer/wallet/payment-code (docs/prd-point-coin.md F9, K8, PC-304). The customer approves with their PIN on their own phone and gets a 6-digit code, as digits and as a QR payload (enakpoint:) for the app to render, valid for two minutes. The PIN is never typed at the cashier. Codes are drawn from crypto/rand and stored in Redis with SET NX and a TTL, bound to the customer; a new code retires the previous one. Redeeming is a single Lua step that uses the code up only if it belongs to the order's customer, so it stays one-time under a race, and a cashier scanning it against the wrong order does not burn it for its owner, which a plain GETDEL would. Expired, used, unknown and other customers' codes are all refused alike. Tests run against miniredis, added as a test dependency. Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 + go.sum | 4 + internal/app/app.go | 5 +- internal/contract/customer_pin_contract.go | 5 + internal/handler/customer_pin_handler.go | 13 ++ internal/models/customer_pin.go | 8 ++ internal/processor/payment_code_processor.go | 101 +++++++++++++ .../processor/payment_code_processor_test.go | 136 ++++++++++++++++++ .../repository/payment_code_repository.go | 103 +++++++++++++ internal/router/router.go | 1 + internal/router/router_test.go | 1 + internal/service/customer_pin_service.go | 18 ++- 12 files changed, 393 insertions(+), 4 deletions(-) create mode 100644 internal/processor/payment_code_processor.go create mode 100644 internal/processor/payment_code_processor_test.go create mode 100644 internal/repository/payment_code_repository.go diff --git a/go.mod b/go.mod index fa0aba6..f58d285 100644 --- a/go.mod +++ b/go.mod @@ -76,6 +76,7 @@ require ( github.com/subosito/gotenv v1.4.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect @@ -107,6 +108,7 @@ require ( require ( firebase.google.com/go/v4 v4.19.0 + github.com/alicebob/miniredis/v2 v2.39.0 github.com/aws/aws-sdk-go v1.55.7 github.com/boombuler/barcode v1.1.0 github.com/golang-jwt/jwt/v5 v5.2.3 diff --git a/go.sum b/go.sum index 3f6c466..b178a33 100644 --- a/go.sum +++ b/go.sum @@ -74,6 +74,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= +github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g= +github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= @@ -349,6 +351,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= diff --git a/internal/app/app.go b/internal/app/app.go index a002680..b5b5e9d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -388,6 +388,7 @@ type processors struct { loyaltySettingsProcessor *processor.LoyaltySettingsProcessor earningProcessor *processor.EarningProcessor customerPinProcessor *processor.CustomerPinProcessor + paymentCodeProcessor *processor.PaymentCodeProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -396,6 +397,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo) // Customer PIN (docs/prd-point-coin.md F11) customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, otpProcessor) + paymentCodeProcessor := processor.NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(a.redisClient), customerPinProcessor) inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo) orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo) @@ -453,6 +455,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor loyaltySettingsProcessor: loyaltySettingsProcessor, earningProcessor: earningProcessor, customerPinProcessor: customerPinProcessor, + paymentCodeProcessor: paymentCodeProcessor, walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), } } @@ -582,7 +585,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor), walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor), loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo), - customerPinService: service.NewCustomerPinService(processors.customerPinProcessor), + customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor), } } diff --git a/internal/contract/customer_pin_contract.go b/internal/contract/customer_pin_contract.go index 9b089eb..8140bd3 100644 --- a/internal/contract/customer_pin_contract.go +++ b/internal/contract/customer_pin_contract.go @@ -26,3 +26,8 @@ type ResetCustomerPinRequest = CreateCustomerPinRequest type RemoveCustomerPinRequest struct { Reason string `json:"reason" binding:"required"` } + +// IssuePaymentCodeRequest is POST /customer/wallet/payment-code. +type IssuePaymentCodeRequest struct { + Pin string `json:"pin" binding:"required"` +} diff --git a/internal/handler/customer_pin_handler.go b/internal/handler/customer_pin_handler.go index 65279f2..c0f9185 100644 --- a/internal/handler/customer_pin_handler.go +++ b/internal/handler/customer_pin_handler.go @@ -136,3 +136,16 @@ func customerIDFromGin(c *gin.Context, method string) (uuid.UUID, bool) { func pinRequestInfo(c *gin.Context) models.CustomerPinRequestInfo { return models.CustomerPinRequestInfo{IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent()} } + +// IssuePaymentCode is POST /customer/wallet/payment-code. +func (h *CustomerPinHandler) IssuePaymentCode(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerPinHandler::IssuePaymentCode") + if !ok { + return + } + var req contract.IssuePaymentCodeRequest + if !bindPinRequest(c, &req, "CustomerPinHandler::IssuePaymentCode") { + return + } + util.HandleResponse(c.Writer, c.Request, h.pinService.IssuePaymentCode(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::IssuePaymentCode") +} diff --git a/internal/models/customer_pin.go b/internal/models/customer_pin.go index fe5477b..6b3616c 100644 --- a/internal/models/customer_pin.go +++ b/internal/models/customer_pin.go @@ -37,3 +37,11 @@ type CustomerPinRequestInfo struct { IPAddress string UserAgent string } + +// PaymentCode is what POST /customer/wallet/payment-code returns: a one-time code the +// customer shows the cashier, as digits or as a QR of QRPayload. +type PaymentCode struct { + Code string `json:"code"` + QRPayload string `json:"qr_payload"` + ExpiresAt time.Time `json:"expires_at"` +} diff --git a/internal/processor/payment_code_processor.go b/internal/processor/payment_code_processor.go new file mode 100644 index 0000000..7c7038c --- /dev/null +++ b/internal/processor/payment_code_processor.go @@ -0,0 +1,101 @@ +package processor + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "math/big" + "strings" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +const ( + paymentCodeDigits = 6 + paymentCodeTTL = 2 * time.Minute + paymentCodeAttempts = 5 + // PaymentCodeQRPrefix marks a scanned QR as an EnakPoint payment code. + PaymentCodeQRPrefix = "enakpoint:" +) + +// ErrPaymentCodeInvalid means the code was never issued, has expired, has been used, +// or belongs to another customer. +var ErrPaymentCodeInvalid = errors.New("payment code is invalid or expired") + +type pinVerifier interface { + VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error +} + +// PaymentCodeProcessor issues and redeems the one-time codes that let a cashier take a +// customer's EnakPoint (docs/prd-point-coin.md F9, K8). The customer approves with +// their PIN on their own phone and shows the code; the PIN is never typed on the +// cashier's device. +type PaymentCodeProcessor struct { + codes repository.PaymentCodeRepository + pins pinVerifier + now func() time.Time +} + +func NewPaymentCodeProcessor(codes repository.PaymentCodeRepository, pins pinVerifier) *PaymentCodeProcessor { + return &PaymentCodeProcessor{codes: codes, pins: pins, now: time.Now} +} + +// Issue checks the customer's PIN and returns a fresh 6-digit code, valid for two +// minutes and bound to the customer. A new code retires the previous one. +func (p *PaymentCodeProcessor) Issue(ctx context.Context, customerID uuid.UUID, pin string, info models.CustomerPinRequestInfo) (*models.PaymentCode, error) { + if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info); err != nil { + return nil, err + } + for attempt := 0; attempt < paymentCodeAttempts; attempt++ { + code, err := randomDigits(paymentCodeDigits) + if err != nil { + return nil, err + } + err = p.codes.Save(ctx, code, customerID, paymentCodeTTL) + if errors.Is(err, repository.ErrPaymentCodeTaken) { + continue + } + if err != nil { + return nil, err + } + return &models.PaymentCode{ + Code: code, + QRPayload: PaymentCodeQRPrefix + code, + ExpiresAt: p.now().Add(paymentCodeTTL), + }, nil + } + return nil, fmt.Errorf("could not draw a free payment code after %d attempts", paymentCodeAttempts) +} + +// Redeem uses a code up for a payment by the given customer. It accepts the code as +// typed or as scanned from the QR. Every failure is ErrPaymentCodeInvalid. +func (p *PaymentCodeProcessor) Redeem(ctx context.Context, code string, customerID uuid.UUID) error { + code = strings.TrimPrefix(strings.TrimSpace(code), PaymentCodeQRPrefix) + if len(code) != paymentCodeDigits { + return ErrPaymentCodeInvalid + } + err := p.codes.Consume(ctx, code, customerID) + if errors.Is(err, repository.ErrPaymentCodeNotFound) || errors.Is(err, repository.ErrPaymentCodeWrongCustomer) { + return ErrPaymentCodeInvalid + } + return err +} + +// randomDigits draws n decimal digits from a cryptographic source, so codes cannot be +// predicted. +func randomDigits(n int) (string, error) { + var b strings.Builder + for i := 0; i < n; i++ { + d, err := rand.Int(rand.Reader, big.NewInt(10)) + if err != nil { + return "", fmt.Errorf("failed to draw a payment code: %w", err) + } + b.WriteByte(byte('0' + d.Int64())) + } + return b.String(), nil +} diff --git a/internal/processor/payment_code_processor_test.go b/internal/processor/payment_code_processor_test.go new file mode 100644 index 0000000..bb134d7 --- /dev/null +++ b/internal/processor/payment_code_processor_test.go @@ -0,0 +1,136 @@ +package processor + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +type pinVerifierFake struct{ good string } + +func (f pinVerifierFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error { + if action != PinActionPay { + return &PinError{Code: "UNEXPECTED_ACTION"} + } + if pin != f.good { + return &PinError{Code: PinErrInvalid, RemainingAttempts: 4} + } + return nil +} + +func newPaymentCodeTest(t *testing.T) (*PaymentCodeProcessor, *miniredis.Miniredis) { + t.Helper() + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { client.Close() }) + return NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"}), mr +} + +func TestPaymentCode_IssueNeedsThePin(t *testing.T) { + p, mr := newPaymentCodeTest(t) + _, err := p.Issue(context.Background(), uuid.New(), "000000", models.CustomerPinRequestInfo{}) + var pe *PinError + require.ErrorAs(t, err, &pe) + assert.Equal(t, PinErrInvalid, pe.Code) + assert.Empty(t, mr.Keys(), "nothing is issued without the PIN") +} + +func TestPaymentCode_Lifecycle(t *testing.T) { + p, mr := newPaymentCodeTest(t) + ctx := context.Background() + customer, other := uuid.New(), uuid.New() + + code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + assert.Len(t, code.Code, 6) + assert.Equal(t, "enakpoint:"+code.Code, code.QRPayload) + assert.WithinDuration(t, time.Now().Add(2*time.Minute), code.ExpiresAt, 2*time.Second) + assert.InDelta(t, 120, mr.TTL("wallet:paycode:"+code.Code).Seconds(), 1, "Redis expires it by itself") + + // A code of another customer is refused, and stays usable by its owner. + assert.ErrorIs(t, p.Redeem(ctx, code.Code, other), ErrPaymentCodeInvalid) + // Scanned from the QR it works; used once, it is gone. + require.NoError(t, p.Redeem(ctx, code.QRPayload, customer)) + assert.ErrorIs(t, p.Redeem(ctx, code.Code, customer), ErrPaymentCodeInvalid) + + // An expired code is refused. + late, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + mr.FastForward(2*time.Minute + time.Second) + assert.ErrorIs(t, p.Redeem(ctx, late.Code, customer), ErrPaymentCodeInvalid) + + // A new code retires the previous one. + first, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + second, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + if first.Code != second.Code { + assert.ErrorIs(t, p.Redeem(ctx, first.Code, customer), ErrPaymentCodeInvalid) + } + require.NoError(t, p.Redeem(ctx, second.Code, customer)) + + // Garbage is refused without touching Redis. + for _, bad := range []string{"", "12345", "1234567", "enakpoint:"} { + assert.ErrorIs(t, p.Redeem(ctx, bad, customer), ErrPaymentCodeInvalid, bad) + } +} + +// Two cashiers scanning the same code at once: exactly one gets it. +func TestPaymentCode_UsedOnceUnderRace(t *testing.T) { + p, _ := newPaymentCodeTest(t) + ctx := context.Background() + customer := uuid.New() + code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + + var wins int32 + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if p.Redeem(ctx, code.Code, customer) == nil { + atomic.AddInt32(&wins, 1) + } + }() + } + wg.Wait() + assert.Equal(t, int32(1), wins) +} + +func TestPaymentCode_SaveRefusesALiveCode(t *testing.T) { + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer client.Close() + repo := repository.NewPaymentCodeRepository(client) + ctx := context.Background() + + require.NoError(t, repo.Save(ctx, "123456", uuid.New(), time.Minute)) + assert.ErrorIs(t, repo.Save(ctx, "123456", uuid.New(), time.Minute), repository.ErrPaymentCodeTaken, + "a live code is never handed to a second customer") +} + +func TestRandomDigits(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 200; i++ { + d, err := randomDigits(6) + require.NoError(t, err) + require.Len(t, d, 6) + for _, r := range d { + require.True(t, r >= '0' && r <= '9') + } + seen[d] = true + } + assert.Greater(t, len(seen), 190, "codes do not repeat") +} diff --git a/internal/repository/payment_code_repository.go b/internal/repository/payment_code_repository.go new file mode 100644 index 0000000..f76fc5b --- /dev/null +++ b/internal/repository/payment_code_repository.go @@ -0,0 +1,103 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" +) + +var ( + // ErrPaymentCodeTaken means the code is already live for someone; draw another. + ErrPaymentCodeTaken = errors.New("payment code already in use") + // ErrPaymentCodeNotFound means the code does not exist: never issued, expired, or + // already used. + ErrPaymentCodeNotFound = errors.New("payment code not found") + // ErrPaymentCodeWrongCustomer means the code belongs to another customer. + ErrPaymentCodeWrongCustomer = errors.New("payment code belongs to another customer") +) + +// PaymentCodeRepository keeps one-time EnakPoint payment codes in Redis +// (docs/prd-point-coin.md F9). A code expires by TTL and is removed when used. +type PaymentCodeRepository interface { + // Save stores a code for a customer for ttl, and retires the customer's previous + // code so only the newest one works. ErrPaymentCodeTaken if the code is live. + Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error + // Consume uses a code up if it belongs to the customer. A code of another customer + // is left in place, so a cashier scanning it against the wrong order does not burn + // it for its owner. + Consume(ctx context.Context, code string, customerID uuid.UUID) error +} + +type paymentCodeRepository struct { + client *redis.Client +} + +func NewPaymentCodeRepository(client *redis.Client) PaymentCodeRepository { + return &paymentCodeRepository{client: client} +} + +func paymentCodeKey(code string) string { return "wallet:paycode:" + code } + +func paymentCodeCustomerKey(customerID uuid.UUID) string { + return "wallet:paycode:customer:" + customerID.String() +} + +func (r *paymentCodeRepository) Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error { + ok, err := r.client.SetNX(ctx, paymentCodeKey(code), customerID.String(), ttl).Result() + if err != nil { + return fmt.Errorf("failed to store payment code: %w", err) + } + if !ok { + return ErrPaymentCodeTaken + } + previous, err := r.client.GetSet(ctx, paymentCodeCustomerKey(customerID), code).Result() + if err != nil && !errors.Is(err, redis.Nil) { + return fmt.Errorf("failed to track payment code: %w", err) + } + r.client.Expire(ctx, paymentCodeCustomerKey(customerID), ttl) + if previous != "" && previous != code { + // Only if it is still that customer's: the number may have been reissued. + if err := r.compareAndDelete(ctx, previous, customerID); err != nil && !errors.Is(err, ErrPaymentCodeNotFound) && !errors.Is(err, ErrPaymentCodeWrongCustomer) { + return err + } + } + return nil +} + +// consumeScript deletes a code only if it belongs to the given customer, in one step. +// Returns 1 when used up, 0 when missing, -1 when it belongs to someone else. +var consumeScript = redis.NewScript(` +local owner = redis.call('GET', KEYS[1]) +if not owner then return 0 end +if owner ~= ARGV[1] then return -1 end +redis.call('DEL', KEYS[1]) +return 1 +`) + +func (r *paymentCodeRepository) Consume(ctx context.Context, code string, customerID uuid.UUID) error { + code = strings.TrimSpace(code) + if code == "" { + return ErrPaymentCodeNotFound + } + return r.compareAndDelete(ctx, code, customerID) +} + +func (r *paymentCodeRepository) compareAndDelete(ctx context.Context, code string, customerID uuid.UUID) error { + result, err := consumeScript.Run(ctx, r.client, []string{paymentCodeKey(code)}, customerID.String()).Int() + if err != nil { + return fmt.Errorf("failed to use payment code: %w", err) + } + switch result { + case 1: + return nil + case -1: + return ErrPaymentCodeWrongCustomer + default: + return ErrPaymentCodeNotFound + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 7070399..3c8e79b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -164,6 +164,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens) customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions) + customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode) // PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11) customer.GET("/pin/status", r.customerPinHandler.Status) customer.POST("/pin/otp", r.customerPinHandler.RequestOtp) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 0049f32..31dfbde 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -35,6 +35,7 @@ func TestAllRoutesRegister(t *testing.T) { "GET /api/v1/marketing/loyalty-settings", "PUT /api/v1/marketing/loyalty-settings", "GET /api/v1/marketing/loyalty-settings/history", + "POST /api/v1/customer/wallet/payment-code", "GET /api/v1/customer/pin/status", "POST /api/v1/customer/pin/otp", "POST /api/v1/customer/pin", diff --git a/internal/service/customer_pin_service.go b/internal/service/customer_pin_service.go index 02c9020..9fdc5b4 100644 --- a/internal/service/customer_pin_service.go +++ b/internal/service/customer_pin_service.go @@ -25,14 +25,18 @@ type CustomerPinService interface { RemovePin(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.RemoveCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response ListSecurityEvents(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, page, limit int) *contract.Response + + // IssuePaymentCode checks the PIN and returns a one-time code for the cashier (F9). + IssuePaymentCode(ctx context.Context, customerID uuid.UUID, req *contract.IssuePaymentCodeRequest, info models.CustomerPinRequestInfo) *contract.Response } type CustomerPinServiceImpl struct { - pins *processor.CustomerPinProcessor + pins *processor.CustomerPinProcessor + codes *processor.PaymentCodeProcessor } -func NewCustomerPinService(pins *processor.CustomerPinProcessor) *CustomerPinServiceImpl { - return &CustomerPinServiceImpl{pins: pins} +func NewCustomerPinService(pins *processor.CustomerPinProcessor, codes *processor.PaymentCodeProcessor) *CustomerPinServiceImpl { + return &CustomerPinServiceImpl{pins: pins, codes: codes} } func (s *CustomerPinServiceImpl) Status(ctx context.Context, customerID uuid.UUID) *contract.Response { @@ -126,3 +130,11 @@ func PinErrorResponse(err error) *contract.Response { contract.NewResponseError(code, constants.CustomerPinServiceEntity, err.Error()), }) } + +func (s *CustomerPinServiceImpl) IssuePaymentCode(ctx context.Context, customerID uuid.UUID, req *contract.IssuePaymentCodeRequest, info models.CustomerPinRequestInfo) *contract.Response { + code, err := s.codes.Issue(ctx, customerID, req.Pin, info) + if err != nil { + return PinErrorResponse(err) + } + return contract.BuildSuccessResponse(code) +} -- 2.54.0 From 4b3beaed41e31867b320a09e4f8c865f21a46290 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 11:46:31 +0700 Subject: [PATCH 30/47] feat(loyalty): pay orders with EnakPoint at the cashier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds paying with the EnakPoint method (docs/prd-point-coin.md F9, K7, PC-305). POST /payments with the EnakPoint method now takes points and the customer's payment code and goes through PointPaymentProcessor instead of the generic path, which would record a payment without taking any balance. After checking the order, its customer (not walk-in, active), the outlet (accepts EnakPoint, minimum) and the method, it redeems the code, then in one transaction locks the order row and the wallet, recomputes the F9 limits from fresh data, inserts the payment with points_used and the frozen point_value, writes the PAYMENT ledger row (key payment:{id}, the outlet, the cashier) and updates the order. The limits are min(balance, floor(min(remaining, total × max_payment_percent / 100 − paid with EnakPoint) / point_value)) in cents, so EnakPoint never pays more than what is left and gives no change. Unlike the generic CreatePayment, which always marks the order paid, an EnakPoint payment leaves it partial with the right remaining amount until it is settled, so the rest can be paid in cash. Settling it triggers earning, whose basis leaves out the EnakPoint part. Splitting with the EnakPoint method is refused. Refusals answer 400. The payment response carries points_used and point_value for the receipt. GET /orders/:id/point-payment/preview returns eligibility, balance, point value and the maximum for the use-maximum button. The payment and order repositories write outside transactions, so this path uses its own repository that joins it. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 8 + internal/contract/order_contract.go | 40 ++- internal/handler/order_handler.go | 8 + internal/handler/point_payment_handler.go | 28 ++ internal/mappers/order_mapper.go | 2 + internal/models/payment.go | 9 +- internal/models/wallet.go | 19 + internal/processor/order_processor.go | 63 +++- internal/processor/point_payment_db_test.go | 334 ++++++++++++++++++ internal/processor/point_payment_processor.go | 321 +++++++++++++++++ .../processor/point_payment_processor_test.go | 39 ++ .../repository/point_payment_repository.go | 178 ++++++++++ internal/router/router.go | 5 +- internal/router/router_test.go | 1 + internal/service/order_service.go | 3 +- internal/service/point_payment_service.go | 55 +++ internal/transformer/order_transformer.go | 4 + 17 files changed, 1096 insertions(+), 21 deletions(-) create mode 100644 internal/handler/point_payment_handler.go create mode 100644 internal/processor/point_payment_db_test.go create mode 100644 internal/processor/point_payment_processor.go create mode 100644 internal/processor/point_payment_processor_test.go create mode 100644 internal/repository/point_payment_repository.go create mode 100644 internal/service/point_payment_service.go diff --git a/internal/app/app.go b/internal/app/app.go index b5b5e9d..23a9f90 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -157,6 +157,7 @@ func (a *App) Initialize(cfg *config.Config) error { validators.walletValidator, services.loyaltySettingsService, services.customerPinService, + services.pointPaymentService, a.redisClient, ) @@ -389,6 +390,7 @@ type processors struct { earningProcessor *processor.EarningProcessor customerPinProcessor *processor.CustomerPinProcessor paymentCodeProcessor *processor.PaymentCodeProcessor + pointPaymentProcessor *processor.PointPaymentProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -405,6 +407,9 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor // Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3) earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) orderProcessor.SetLoyalty(earningProcessor) + // Pay orders with EnakPoint, approved by the customer's one-time code (docs/prd-point-coin.md F9) + pointPaymentProcessor := processor.NewPointPaymentProcessor(repository.NewPointPaymentRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) + orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor) return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), @@ -456,6 +461,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor earningProcessor: earningProcessor, customerPinProcessor: customerPinProcessor, paymentCodeProcessor: paymentCodeProcessor, + pointPaymentProcessor: pointPaymentProcessor, walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), } } @@ -502,6 +508,7 @@ type services struct { walletAdminService *service.WalletAdminServiceImpl loyaltySettingsService *service.LoyaltySettingsServiceImpl customerPinService *service.CustomerPinServiceImpl + pointPaymentService *service.PointPaymentServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -586,6 +593,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor), loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo), customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor), + pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor), } } diff --git a/internal/contract/order_contract.go b/internal/contract/order_contract.go index 5926775..4ce5f20 100644 --- a/internal/contract/order_contract.go +++ b/internal/contract/order_contract.go @@ -186,9 +186,13 @@ type SetOrderCustomerResponse struct { } type CreatePaymentRequest struct { - OrderID uuid.UUID `json:"order_id" validate:"required"` - PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"` - Amount float64 `json:"amount" validate:"required,min=0"` + OrderID uuid.UUID `json:"order_id" validate:"required"` + PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"` + // For the EnakPoint method: how many to use and the code the customer shows. The + // amount is then computed from them. + Points *int64 `json:"points,omitempty"` + PaymentCode *string `json:"payment_code,omitempty"` + Amount float64 `json:"amount" validate:"min=0"` TransactionID *string `json:"transaction_id,omitempty" validate:"omitempty"` SplitNumber int `json:"split_number,omitempty" validate:"omitempty,min=1"` SplitTotal int `json:"split_total,omitempty" validate:"omitempty,min=1"` @@ -204,19 +208,23 @@ type CreatePaymentOrderItemRequest struct { } type PaymentResponse struct { - ID uuid.UUID `json:"id"` - OrderID uuid.UUID `json:"order_id"` - PaymentMethodID uuid.UUID `json:"payment_method_id"` - PaymentMethodName string `json:"payment_method_name"` - PaymentMethodType string `json:"payment_method_type"` - Amount float64 `json:"amount"` - Status string `json:"status"` - TransactionID *string `json:"transaction_id,omitempty"` - SplitNumber int `json:"split_number"` - SplitTotal int `json:"split_total"` - SplitType *string `json:"split_type,omitempty"` - SplitDescription *string `json:"split_description,omitempty"` - RefundAmount float64 `json:"refund_amount"` + ID uuid.UUID `json:"id"` + OrderID uuid.UUID `json:"order_id"` + PaymentMethodID uuid.UUID `json:"payment_method_id"` + PaymentMethodName string `json:"payment_method_name"` + PaymentMethodType string `json:"payment_method_type"` + Amount float64 `json:"amount"` + Status string `json:"status"` + TransactionID *string `json:"transaction_id,omitempty"` + SplitNumber int `json:"split_number"` + SplitTotal int `json:"split_total"` + SplitType *string `json:"split_type,omitempty"` + SplitDescription *string `json:"split_description,omitempty"` + RefundAmount float64 `json:"refund_amount"` + // Set for a payment with EnakPoint, for "EnakPoint: 50.000 (Rp 50.000)" on the + // receipt (docs/prd-point-coin.md F9). + PointsUsed *int64 `json:"points_used,omitempty"` + PointValue *float64 `json:"point_value,omitempty"` RefundReason *string `json:"refund_reason,omitempty"` RefundedAt *time.Time `json:"refunded_at,omitempty"` RefundedBy *uuid.UUID `json:"refunded_by,omitempty"` diff --git a/internal/handler/order_handler.go b/internal/handler/order_handler.go index fa3971b..bfcab38 100644 --- a/internal/handler/order_handler.go +++ b/internal/handler/order_handler.go @@ -1,8 +1,11 @@ package handler import ( + "errors" + "apskel-pos-be/internal/appcontext" "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/processor" "apskel-pos-be/internal/service" "apskel-pos-be/internal/transformer" "apskel-pos-be/internal/util" @@ -225,6 +228,11 @@ func (h *OrderHandler) CreatePayment(c *gin.Context) { response, err := h.orderService.CreatePayment(c.Request.Context(), modelReq) if err != nil { + // A refused EnakPoint payment is the cashier's or customer's to fix, not a server fault. + if errors.Is(err, processor.ErrPointPaymentRejected) { + util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::CreatePayment") + return + } util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::CreatePayment", err.Error())}), "OrderHandler::CreatePayment") return } diff --git a/internal/handler/point_payment_handler.go b/internal/handler/point_payment_handler.go new file mode 100644 index 0000000..3c1f3e2 --- /dev/null +++ b/internal/handler/point_payment_handler.go @@ -0,0 +1,28 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" +) + +// PointPaymentHandler serves GET /orders/:id/point-payment/preview +// (docs/prd-point-coin.md F9). +type PointPaymentHandler struct { + pointPaymentService service.PointPaymentService +} + +func NewPointPaymentHandler(pointPaymentService service.PointPaymentService) *PointPaymentHandler { + return &PointPaymentHandler{pointPaymentService: pointPaymentService} +} + +func (h *PointPaymentHandler) Preview(c *gin.Context) { + orderID, ok := parseUUIDParam(c, "id", "PointPaymentHandler::Preview") + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.pointPaymentService.Preview(ctx, appcontext.FromGinContext(ctx), orderID), "PointPaymentHandler::Preview") +} diff --git a/internal/mappers/order_mapper.go b/internal/mappers/order_mapper.go index 5f68ce2..8a12404 100644 --- a/internal/mappers/order_mapper.go +++ b/internal/mappers/order_mapper.go @@ -189,6 +189,8 @@ func PaymentEntityToResponse(payment *entities.Payment) *models.PaymentResponse SplitType: (*string)(payment.SplitType), SplitDescription: payment.SplitDescription, RefundAmount: payment.RefundAmount, + PointsUsed: payment.PointsUsed, + PointValue: payment.PointValue, RefundReason: payment.RefundReason, RefundedAt: payment.RefundedAt, RefundedBy: payment.RefundedBy, diff --git a/internal/models/payment.go b/internal/models/payment.go index bf35894..1269b9e 100644 --- a/internal/models/payment.go +++ b/internal/models/payment.go @@ -28,8 +28,10 @@ type Payment struct { } type CreatePaymentRequest struct { - OrderID uuid.UUID `validate:"required"` - PaymentMethodID uuid.UUID `validate:"required"` + OrderID uuid.UUID `validate:"required"` + PaymentMethodID uuid.UUID `validate:"required"` + Points *int64 + PaymentCode *string Amount float64 `validate:"required,min=0"` TransactionID *string `validate:"omitempty"` SplitNumber int `validate:"omitempty,min=1"` @@ -60,6 +62,9 @@ type PaymentResponse struct { SplitType *string SplitDescription *string RefundAmount float64 + // Set for a payment with EnakPoint. + PointsUsed *int64 + PointValue *float64 RefundReason *string RefundedAt *time.Time RefundedBy *uuid.UUID diff --git a/internal/models/wallet.go b/internal/models/wallet.go index e9fe804..da38e30 100644 --- a/internal/models/wallet.go +++ b/internal/models/wallet.go @@ -138,3 +138,22 @@ type AdminWalletAdjustmentResult struct { // True when the idempotency key had been used before and nothing changed. Replayed bool `json:"replayed"` } + +// PointPaymentPreview is GET /orders/:id/point-payment/preview (docs/prd-point-coin.md +// F9): whether the order can be paid with EnakPoint and at most how much, for the +// cashier's "use maximum" button. +type PointPaymentPreview struct { + OrderID uuid.UUID `json:"order_id"` + CustomerID *uuid.UUID `json:"customer_id"` + Eligible bool `json:"eligible"` + // Why not, when not eligible. + Reason string `json:"reason,omitempty"` + PointBalance int64 `json:"point_balance"` + PointValue int64 `json:"point_value"` + RemainingAmount float64 `json:"remaining_amount"` + MinPaymentPoints int64 `json:"min_payment_points"` + MaxPaymentPercent int64 `json:"max_payment_percent"` + MaxPoints int64 `json:"max_points"` + // Rupiah covered by MaxPoints. + MaxAmount int64 `json:"max_amount"` +} diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 0bd9bc5..701633c 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "apskel-pos-be/internal/appcontext" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/logger" @@ -111,6 +112,8 @@ type OrderProcessorImpl struct { inventoryMovementService InventoryMovementService productOutletPriceRepo repository.ProductOutletPriceRepository loyalty OrderLoyalty + pointPayments *PointPaymentProcessor + paymentCodes paymentCodeRedeemer } // OrderLoyalty is what the order flow tells and asks the loyalty program @@ -137,6 +140,56 @@ func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) { p.loyalty = loyalty } +type paymentCodeRedeemer interface { + Redeem(ctx context.Context, code string, customerID uuid.UUID) error +} + +// SetPointPayments enables paying with the EnakPoint method: CreatePayment hands such +// payments to pointPayments, approved by the code the customer shows (F9). +func (p *OrderProcessorImpl) SetPointPayments(pointPayments *PointPaymentProcessor, codes paymentCodeRedeemer) { + p.pointPayments = pointPayments + p.paymentCodes = codes +} + +// createPointPayment is CreatePayment for the EnakPoint method. It never uses the +// generic payment path, which would record the payment without taking any balance. +func (p *OrderProcessorImpl) createPointPayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) { + if p.pointPayments == nil || p.paymentCodes == nil { + return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected) + } + if req.Points == nil || req.PaymentCode == nil || *req.PaymentCode == "" { + return nil, fmt.Errorf("%w: points and the customer's payment code are required", ErrPointPaymentRejected) + } + var cashier *uuid.UUID + if id := appcontext.FromContext(ctx).UserID; id != uuid.Nil { + cashier = &id + } + code := *req.PaymentCode + result, err := p.pointPayments.Pay(ctx, PointPaymentInput{ + OrderID: req.OrderID, + PaymentMethodID: req.PaymentMethodID, + Points: *req.Points, + CashierID: cashier, + Authorize: func(ctx context.Context, customerID uuid.UUID) error { + if err := p.paymentCodes.Redeem(ctx, code, customerID); err != nil { + return fmt.Errorf("%w: %v", ErrPointPaymentRejected, err) + } + return nil + }, + }) + if err != nil { + return nil, err + } + if result.Completed { + p.onOrderPaid(ctx, req.OrderID) + } + payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID) + if err != nil { + return nil, fmt.Errorf("failed to retrieve created payment: %w", err) + } + return mappers.PaymentEntityToResponse(payment), nil +} + // onOrderPaid is the single place every path that completes an order's payment goes // through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called // after the payment has committed. The hook runs detached from the caller's @@ -892,10 +945,13 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea return nil, fmt.Errorf("order is already fully paid") } - _, err = p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID) + method, err := p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID) if err != nil { return nil, fmt.Errorf("payment method not found: %w", err) } + if method.Type == entities.PaymentMethodTypePoint { + return p.createPointPayment(ctx, req) + } totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, req.OrderID) if err != nil { @@ -1280,6 +1336,11 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil if err != nil { return nil, fmt.Errorf("payment method not found: %w", err) } + // Splitting with EnakPoint would record a payment without taking any balance; an + // EnakPoint part goes through CreatePayment and the rest is split as usual (F9). + if payment.Type == entities.PaymentMethodTypePoint { + return nil, fmt.Errorf("%w: pay the EnakPoint part as its own payment, not as a split", ErrPointPaymentRejected) + } customer := &entities.Customer{} if req.CustomerID != uuid.Nil { diff --git a/internal/processor/point_payment_db_test.go b/internal/processor/point_payment_db_test.go new file mode 100644 index 0000000..cd846ac --- /dev/null +++ b/internal/processor/point_payment_db_test.go @@ -0,0 +1,334 @@ +package processor + +import ( + "context" + "os" + "sync" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "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/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// pointPaymentEnv is an order flow wired as in the app, against Postgres and a +// miniredis for payment codes. +type pointPaymentEnv struct { + t *testing.T + db *gorm.DB + orders *OrderProcessorImpl + payments *PointPaymentProcessor + codes *PaymentCodeProcessor + org uuid.UUID + cashier uuid.UUID + outlet uuid.UUID + point uuid.UUID + cash uuid.UUID + walkIn uuid.UUID + ctx context.Context +} + +func newPointPaymentEnv(t *testing.T) *pointPaymentEnv { + t.Helper() + 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) + e := &pointPaymentEnv{t: t, db: db, org: uuid.New(), cashier: uuid.New(), outlet: uuid.New(), cash: uuid.New()} + e.exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'point pay test', 'basic')`, e.org) + e.exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, e.cashier, e.org, e.cashier.String()+"@t") + e.exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, e.outlet, e.org) + e.exec(`INSERT INTO payment_methods (id, organization_id, name, type) VALUES (?, ?, 'Tunai', 'cash')`, e.cash, e.org) + var ids []string + require.NoError(t, db.Raw(`SELECT id::text FROM payment_methods WHERE organization_id = ? AND type = 'point'`, e.org).Scan(&ids).Error) + require.Len(t, ids, 1) + e.point = uuid.MustParse(ids[0]) + require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, e.org).Scan(&ids).Error) + e.walkIn = uuid.MustParse(ids[0]) + t.Cleanup(func() { + db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, e.org) + db.Exec(`DELETE FROM wallet_lots WHERE organization_id = ?`, e.org) + db.Exec(`DELETE FROM wallet_transactions WHERE organization_id = ?`, e.org) + db.Exec(`DELETE FROM customer_wallets WHERE organization_id = ?`, e.org) + db.Exec(`DELETE FROM payments WHERE order_id IN (SELECT id FROM orders WHERE organization_id = ?)`, e.org) + db.Exec(`DELETE FROM orders WHERE organization_id = ?`, e.org) + db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, e.org) + db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, e.outlet) + db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, e.org) + db.Exec(`DELETE FROM customers WHERE organization_id = ?`, e.org) + db.Exec(`DELETE FROM outlets WHERE id = ?`, e.outlet) + db.Exec(`DELETE FROM users WHERE id = ?`, e.cashier) + db.Exec(`DELETE FROM organizations WHERE id = ?`, e.org) + }) + + txm := repository.NewTxManager(db) + settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) + wallet := NewWalletProcessor(repository.NewWalletRepository(db)) + e.payments = NewPointPaymentProcessor(repository.NewPointPaymentRepository(db), settings, repository.NewWalletQueryRepository(db), wallet, txm) + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { client.Close() }) + e.codes = NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"}) + + e.orders = &OrderProcessorImpl{ + orderRepo: repository.NewOrderRepositoryImpl(db), + orderItemRepo: repository.NewOrderItemRepositoryImpl(db), + paymentRepo: repository.NewPaymentRepositoryImpl(db), + paymentMethodRepo: repository.NewPaymentMethodRepositoryImpl(db), + splitBillProcessor: nil, + txManager: txm, + } + e.orders.SetLoyalty(NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm)) + e.orders.SetPointPayments(e.payments, e.codes) + + // The outlet earns 1 EnakPoint per Rp 100 and accepts EnakPoint. + s, err := settings.Outlet(context.Background(), e.outlet) + require.NoError(t, err) + s.Point.Enabled = true + s.PointPayment.AcceptPayment = true + _, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s) + require.NoError(t, err) + + e.ctx = context.WithValue(context.Background(), appcontext.UserIDKey, e.cashier.String()) + return e +} + +func (e *pointPaymentEnv) exec(q string, args ...any) { + e.t.Helper() + require.NoError(e.t, e.db.Exec(q, args...).Error) +} + +// customerWith creates a customer holding the given EnakPoint. +func (e *pointPaymentEnv) customerWith(points int64) uuid.UUID { + e.t.Helper() + id := uuid.New() + e.exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, id, e.org) + if points > 0 { + require.NoError(e.t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error { + _, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{ + CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: points, + ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}}) + return err + })) + } + return id +} + +func (e *pointPaymentEnv) order(customer uuid.UUID, subtotal float64) uuid.UUID { + e.t.Helper() + id := uuid.New() + e.exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type, + subtotal, tax_amount, total_amount, remaining_amount, payment_status) + VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, 0, ?, ?, 'pending')`, + id, e.org, e.outlet, e.cashier, customer, "PP-"+id.String()[:8], subtotal, subtotal, subtotal) + return id +} + +func (e *pointPaymentEnv) code(customer uuid.UUID) string { + e.t.Helper() + c, err := e.codes.Issue(context.Background(), customer, "482913", models.CustomerPinRequestInfo{}) + require.NoError(e.t, err) + return c.Code +} + +func (e *pointPaymentEnv) payPoints(order uuid.UUID, points int64, code string) (*models.PaymentResponse, error) { + return e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &points, PaymentCode: &code}) +} + +func (e *pointPaymentEnv) balance(customer uuid.UUID) int64 { + e.t.Helper() + var b int64 + require.NoError(e.t, e.db.Raw(`SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error) + return b +} + +func (e *pointPaymentEnv) orderState(order uuid.UUID) (status string, remaining float64) { + e.t.Helper() + var row struct { + PaymentStatus string + RemainingAmount float64 + } + require.NoError(e.t, e.db.Raw(`SELECT payment_status, remaining_amount FROM orders WHERE id = ?`, order).Scan(&row).Error) + return row.PaymentStatus, row.RemainingAmount +} + +func TestPointPayment_FullPayment(t *testing.T) { + e := newPointPaymentEnv(t) + customer := e.customerWith(100000) + order := e.order(customer, 50000) + + payment, err := e.payPoints(order, 50000, e.code(customer)) + require.NoError(t, err) + assert.Equal(t, 50000.0, payment.Amount) + require.NotNil(t, payment.PointsUsed) + assert.Equal(t, int64(50000), *payment.PointsUsed) + assert.Equal(t, 1.0, *payment.PointValue, "the value is frozen on the payment") + status, remaining := e.orderState(order) + assert.Equal(t, "completed", status) + assert.Zero(t, remaining) + assert.Equal(t, int64(50000), e.balance(customer)) + + var ledger struct { + Amount int64 + ReferenceType string + ReferenceID string + OutletID string + CreatedByUser string + } + require.NoError(t, e.db.Raw(`SELECT amount, reference_type, reference_id::text AS reference_id, outlet_id::text AS outlet_id, + created_by_user::text AS created_by_user FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT'`, customer).Scan(&ledger).Error) + assert.Equal(t, int64(-50000), ledger.Amount) + assert.Equal(t, "PAYMENT", ledger.ReferenceType) + assert.Equal(t, payment.ID.String(), ledger.ReferenceID) + assert.Equal(t, e.outlet.String(), ledger.OutletID) + assert.Equal(t, e.cashier.String(), ledger.CreatedByUser, "the cashier who took it") + + // Paid entirely with EnakPoint, so nothing earns (Q10). + var earned int64 + require.NoError(t, e.db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error) + assert.Zero(t, earned) +} + +func TestPointPayment_PartialThenCash(t *testing.T) { + e := newPointPaymentEnv(t) + customer := e.customerWith(100000) + order := e.order(customer, 87500) + + _, err := e.payPoints(order, 20000, e.code(customer)) + require.NoError(t, err) + status, remaining := e.orderState(order) + assert.Equal(t, "partial", status) + assert.Equal(t, 67500.0, remaining) + + // The rest in cash settles it; earning counts only the part not paid with + // EnakPoint: floor(67.500 / 100) = 675. + _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500}) + require.NoError(t, err) + status, _ = e.orderState(order) + assert.Equal(t, "completed", status) + var earned int64 + require.NoError(t, e.db.Raw(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error) + assert.Equal(t, int64(675), earned) + assert.Equal(t, int64(100000-20000+675), e.balance(customer)) +} + +func TestPointPayment_PercentCap(t *testing.T) { + e := newPointPaymentEnv(t) + settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db)) + s, err := settings.Outlet(context.Background(), e.outlet) + require.NoError(t, err) + s.PointPayment.MaxPaymentPercent = 50 + _, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s) + require.NoError(t, err) + + customer := e.customerWith(100000) + order := e.order(customer, 100000) + + preview, err := e.payments.Preview(context.Background(), e.org, order) + require.NoError(t, err) + assert.True(t, preview.Eligible) + assert.Equal(t, int64(50000), preview.MaxPoints) + assert.Equal(t, int64(100000), preview.PointBalance) + + _, err = e.payPoints(order, 50001, e.code(customer)) + assert.ErrorIs(t, err, ErrPointPaymentRejected) + _, err = e.payPoints(order, 30000, e.code(customer)) + require.NoError(t, err) + _, err = e.payPoints(order, 20001, e.code(customer)) + assert.ErrorIs(t, err, ErrPointPaymentRejected, "earlier EnakPoint counts toward the cap") + _, err = e.payPoints(order, 20000, e.code(customer)) + require.NoError(t, err) + assert.Equal(t, int64(50000), e.balance(customer)) +} + +func TestPointPayment_Refusals(t *testing.T) { + e := newPointPaymentEnv(t) + customer := e.customerWith(100000) + other := e.customerWith(100000) + + // A walk-in order cannot be paid with EnakPoint. + walkInOrder := e.order(e.walkIn, 10000) + preview, err := e.payments.Preview(context.Background(), e.org, walkInOrder) + require.NoError(t, err) + assert.False(t, preview.Eligible) + assert.Contains(t, preview.Reason, "walk-in") + _, err = e.payPoints(walkInOrder, 1000, "123456") + assert.ErrorIs(t, err, ErrPointPaymentRejected) + + order := e.order(customer, 10000) + _, err = e.payPoints(order, 1000, "000000") + assert.ErrorIs(t, err, ErrPointPaymentRejected, "a wrong code") + _, err = e.payPoints(order, 1000, e.code(other)) + assert.ErrorIs(t, err, ErrPointPaymentRejected, "another customer's code") + missing := int64(1000) + _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &missing}) + assert.ErrorIs(t, err, ErrPointPaymentRejected, "no code at all") + + code := e.code(customer) + _, err = e.payPoints(order, 1000, code) + require.NoError(t, err) + _, err = e.payPoints(order, 1000, code) + assert.ErrorIs(t, err, ErrPointPaymentRejected, "a code is used once, so a double tap takes once") + _, err = e.payPoints(order, 9001, e.code(customer)) + assert.ErrorIs(t, err, ErrPointPaymentRejected, "no change is given: not more than what is left") + + // Splitting with the EnakPoint method would skip the balance, so it is refused. + e.orders.splitBillProcessor = splitFake{} + _, err = e.orders.SplitBill(e.ctx, &models.SplitBillRequest{OrderID: order, PaymentMethodID: e.point, Type: "AMOUNT", Amount: 1000}) + assert.ErrorIs(t, err, ErrPointPaymentRejected) + + assert.Equal(t, int64(99000), e.balance(customer), "only the one payment took anything") + assert.Equal(t, int64(100000), e.balance(other)) +} + +// Two payments for the same customer at once, on two orders: the balance is taken +// once, never twice. Authorization is taken as given so only the balance decides. +func TestPointPayment_ConcurrentForOneCustomer(t *testing.T) { + e := newPointPaymentEnv(t) + customer := e.customerWith(30000) + orders := []uuid.UUID{e.order(customer, 20000), e.order(customer, 20000)} + + var wg sync.WaitGroup + results := make([]error, len(orders)) + for i, order := range orders { + wg.Add(1) + go func(i int, order uuid.UUID) { + defer wg.Done() + _, results[i] = e.payments.Pay(e.ctx, PointPaymentInput{ + OrderID: order, PaymentMethodID: e.point, Points: 20000, + Authorize: func(context.Context, uuid.UUID) error { return nil }, + }) + }(i, order) + } + wg.Wait() + + succeeded := 0 + for _, err := range results { + if err == nil { + succeeded++ + } else { + assert.ErrorIs(t, err, ErrPointPaymentRejected) + } + } + assert.Equal(t, 1, succeeded, "30.000 EnakPoint pays one 20.000 order, not two") + assert.Equal(t, int64(10000), e.balance(customer)) + + // And the wallet still reconciles. + found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000) + require.NoError(t, err) + for _, d := range found { + assert.NotEqual(t, customer, d.CustomerID, d.Check) + } +} diff --git a/internal/processor/point_payment_processor.go b/internal/processor/point_payment_processor.go new file mode 100644 index 0000000..144ebf1 --- /dev/null +++ b/internal/processor/point_payment_processor.go @@ -0,0 +1,321 @@ +package processor + +import ( + "context" + "errors" + "fmt" + "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" +) + +// ErrPointPaymentRejected wraps every reason a payment with EnakPoint is refused: the +// order, the customer, the outlet or the amount. The message says which. +var ErrPointPaymentRejected = errors.New("EnakPoint payment refused") + +type pointPaymentSettings interface { + Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) + PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) +} + +type spendableReader interface { + SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) +} + +// PointPaymentInput is one payment of an order with EnakPoint. +type PointPaymentInput struct { + OrderID uuid.UUID + PaymentMethodID uuid.UUID + Points int64 + // The cashier taking the payment at the POS; nil when the customer pays in the app. + CashierID *uuid.UUID + // Authorize proves the customer agreed, before anything is taken: at the POS it + // redeems the payment code, in the app it checks the PIN (K8). + Authorize func(ctx context.Context, customerID uuid.UUID) error +} + +// PointPaymentResult is the payment made and where it left the order. +type PointPaymentResult struct { + Payment *entities.Payment + // True when this payment settled the order. + Completed bool + // Rupiah still to pay with another method. + Remaining float64 +} + +// pointPaymentLimits applies the formula of docs/prd-point-coin.md F9, in cents: +// +// cap = min(remaining, total × max_payment_percent / 100 − already paid with EnakPoint) +// max_points = min(balance, floor(cap / point_value)) +type pointPaymentLimits struct { + RemainingCents int64 + CapCents int64 + MaxPoints int64 +} + +func computePointPaymentLimits(total, totalPaid, paidWithPoints float64, maxPercent, pointValue, balance int64) pointPaymentLimits { + remaining := toCents(total) - toCents(totalPaid) + if remaining < 0 { + remaining = 0 + } + byPercent := toCents(total)*maxPercent/100 - toCents(paidWithPoints) + capCents := min(remaining, byPercent) + if capCents < 0 { + capCents = 0 + } + maxPoints := int64(0) + if pointValue > 0 { + maxPoints = min(balance, capCents/(pointValue*100)) + } + if maxPoints < 0 { + maxPoints = 0 + } + return pointPaymentLimits{RemainingCents: remaining, CapCents: capCents, MaxPoints: maxPoints} +} + +// PointPaymentProcessor pays orders with EnakPoint (docs/prd-point-coin.md F9). +type PointPaymentProcessor struct { + repo repository.PointPaymentRepository + settings pointPaymentSettings + spendable spendableReader + wallet *WalletProcessor + tx TxRunner + now func() time.Time +} + +func NewPointPaymentProcessor(repo repository.PointPaymentRepository, settings pointPaymentSettings, spendable spendableReader, wallet *WalletProcessor, tx TxRunner) *PointPaymentProcessor { + return &PointPaymentProcessor{repo: repo, settings: settings, spendable: spendable, wallet: wallet, tx: tx, now: time.Now} +} + +// Preview is GET /orders/:id/point-payment/preview: whether the order can be paid with +// EnakPoint, and at most how much. +func (p *PointPaymentProcessor) Preview(ctx context.Context, organizationID, orderID uuid.UUID) (*models.PointPaymentPreview, error) { + order, err := p.repo.GetOrder(ctx, orderID, false) + if err != nil { + return nil, err + } + if order.OrganizationID != organizationID { + return nil, repository.ErrPointPaymentOrderNotFound + } + preview := &models.PointPaymentPreview{OrderID: orderID, CustomerID: order.CustomerID} + settings, err := p.settings.Outlet(ctx, order.OutletID) + if err != nil { + return nil, err + } + value, err := p.settings.PointValue(ctx, order.OrganizationID) + if err != nil { + return nil, err + } + preview.PointValue = value + preview.MinPaymentPoints = settings.PointPayment.MinPaymentPoints + preview.MaxPaymentPercent = settings.PointPayment.MaxPaymentPercent + + if reason := pointPaymentOrderProblem(order, settings); reason != "" { + preview.Reason = reason + return preview, nil + } + limits, balance, err := p.limits(ctx, order, settings, value) + if err != nil { + return nil, err + } + preview.PointBalance = balance + preview.RemainingAmount = float64(limits.RemainingCents) / 100 + preview.MaxPoints = limits.MaxPoints + preview.MaxAmount = limits.MaxPoints * value + if limits.MaxPoints < settings.PointPayment.MinPaymentPoints { + preview.Reason = "the customer cannot pay the minimum of EnakPoint on this order" + return preview, nil + } + preview.Eligible = true + return preview, nil +} + +// Pay takes EnakPoint from the order's customer and records the payment. The payment +// row, the ledger PAYMENT row, the balance and the order change in one transaction +// with the order row and the wallet locked (F9 steps 1–6). +func (p *PointPaymentProcessor) Pay(ctx context.Context, in PointPaymentInput) (*PointPaymentResult, error) { + reject := func(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrPointPaymentRejected, fmt.Sprintf(format, args...)) + } + if in.Points <= 0 { + return nil, reject("the number of EnakPoint must be positive") + } + order, err := p.repo.GetOrder(ctx, in.OrderID, false) + if err != nil { + return nil, err + } + methodOrg, methodType, err := p.repo.GetPaymentMethod(ctx, in.PaymentMethodID) + if err != nil { + return nil, err + } + if methodType != string(constants.PaymentMethodTypePoint) || methodOrg != order.OrganizationID { + return nil, reject("the payment method is not this organization's EnakPoint method") + } + settings, err := p.settings.Outlet(ctx, order.OutletID) + if err != nil { + return nil, err + } + if reason := pointPaymentOrderProblem(order, settings); reason != "" { + return nil, reject("%s", reason) + } + if in.Points < settings.PointPayment.MinPaymentPoints { + return nil, reject("at least %d EnakPoint must be used", settings.PointPayment.MinPaymentPoints) + } + customerID := *order.CustomerID + + // The customer agrees before anything is taken. A code is used up here even if the + // payment then fails, and the customer shows a new one. + if in.Authorize == nil { + return nil, reject("the customer has not approved the payment") + } + if err := in.Authorize(ctx, customerID); err != nil { + return nil, err + } + + result := &PointPaymentResult{} + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + // Lock the order, then the wallet, and read everything again: another payment + // may have landed since the checks above. + order, err := p.repo.GetOrder(ctx, in.OrderID, true) + if err != nil { + return err + } + if reason := pointPaymentOrderProblem(order, settings); reason != "" { + return reject("%s", reason) + } + if err := p.wallet.LockWallet(ctx, customerID); err != nil { + return err + } + value, err := p.settings.PointValue(ctx, order.OrganizationID) + if err != nil { + return err + } + limits, _, err := p.limits(ctx, order, settings, value) + if err != nil { + return err + } + if in.Points > limits.MaxPoints { + return reject("at most %d EnakPoint can pay this order now", limits.MaxPoints) + } + amountCents := in.Points * value * 100 + // No change is ever given for EnakPoint (K7); the limits already keep it + // within what is left, this only guards that. + if amountCents > limits.RemainingCents { + return reject("EnakPoint cannot pay more than what is left on the order") + } + + pointsUsed := in.Points + frozenValue := float64(value) + payment := &entities.Payment{ + ID: uuid.New(), + OrderID: order.ID, + PaymentMethodID: in.PaymentMethodID, + Amount: float64(amountCents) / 100, + Status: entities.PaymentTransactionStatusCompleted, + PointsUsed: &pointsUsed, + PointValue: &frozenValue, + Metadata: entities.Metadata{"points_used": pointsUsed, "point_value": value}, + } + if err := p.repo.InsertPayment(ctx, payment); err != nil { + return err + } + + outletID := order.OutletID + if _, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ + CustomerID: customerID, + Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypePayment, + Amount: in.Points, + ReferenceType: constants.WalletRefTypePayment, + ReferenceID: payment.ID, + OutletID: &outletID, + CreatedByUser: in.CashierID, + Description: pointPaymentDescription(order, amountCents), + Metadata: entities.Metadata{"point_value": value, "amount": payment.Amount}, + IdempotencyKey: "payment:" + payment.ID.String(), + }}); err != nil { + if errors.Is(err, repository.ErrWalletInsufficientBalance) { + return reject("the customer does not have enough EnakPoint") + } + return err + } + + remainingCents := limits.RemainingCents - amountCents + completed := remainingCents == 0 + if err := p.repo.UpdateOrderAfterPayment(ctx, order.ID, float64(remainingCents)/100, completed); err != nil { + return err + } + result.Payment = payment + result.Completed = completed + result.Remaining = float64(remainingCents) / 100 + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func (p *PointPaymentProcessor) limits(ctx context.Context, order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings, value int64) (pointPaymentLimits, int64, error) { + totalPaid, err := p.repo.TotalPaid(ctx, order.ID) + if err != nil { + return pointPaymentLimits{}, 0, err + } + paidWithPoints, err := p.repo.PaidWithPoints(ctx, order.ID) + if err != nil { + return pointPaymentLimits{}, 0, err + } + balances, err := p.spendable.SpendableBalances(ctx, *order.CustomerID, p.now()) + if err != nil { + return pointPaymentLimits{}, 0, err + } + balance := balances[constants.WalletCurrencyPoint] + return computePointPaymentLimits(order.TotalAmount, totalPaid, paidWithPoints, settings.PointPayment.MaxPaymentPercent, value, balance), balance, nil +} + +// pointPaymentOrderProblem says why an order cannot be paid with EnakPoint at all, or +// "" when it can. +func pointPaymentOrderProblem(order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings) string { + switch { + case order.IsVoid: + return "the order is void" + case order.PaymentStatus == string(entities.PaymentStatusCompleted): + return "the order is already paid" + case !settings.PointPayment.AcceptPayment: + return "this outlet does not accept EnakPoint" + case order.CustomerID == nil || order.CustomerIsDefault == nil: + return "the order has no customer" + case *order.CustomerIsDefault: + return "a walk-in order cannot be paid with EnakPoint" + case order.CustomerIsActive == nil || !*order.CustomerIsActive: + return "the customer is not active" + } + return "" +} + +func pointPaymentDescription(order *repository.PointPaymentOrder, amountCents int64) string { + description := "Bayar #" + order.OrderNumber + if order.OutletName != "" { + description += " di " + order.OutletName + } + description += " (Rp " + formatRupiah(amountCents/100) + ")" + return truncateRunes(description, walletDescriptionLimit) +} + +// formatRupiah writes 50000 as 50.000. +func formatRupiah(n int64) string { + s := fmt.Sprintf("%d", n) + out := make([]byte, 0, len(s)+len(s)/3) + for i, c := range []byte(s) { + if i > 0 && (len(s)-i)%3 == 0 { + out = append(out, '.') + } + out = append(out, c) + } + return string(out) +} diff --git a/internal/processor/point_payment_processor_test.go b/internal/processor/point_payment_processor_test.go new file mode 100644 index 0000000..4b879c3 --- /dev/null +++ b/internal/processor/point_payment_processor_test.go @@ -0,0 +1,39 @@ +package processor + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestComputePointPaymentLimits(t *testing.T) { + for name, c := range map[string]struct { + total, paid, paidWithPoints float64 + percent, value, balance int64 + wantRemaining, wantMax int64 + }{ + // The F9 example: Rp 87.550 left, 50.000 EnakPoint, 100%, Rp 1 a point. + "PRD example": {87550, 0, 0, 100, 1, 50000, 8755000, 50000}, + "balance covers it all": {87550, 0, 0, 100, 1, 100000, 8755000, 87550}, + "part already paid": {100000, 30000, 0, 100, 1, 100000, 7000000, 70000}, + "capped by percent": {100000, 0, 0, 50, 1, 100000, 10000000, 50000}, + "percent counts EnakPoint already used": {100000, 20000, 20000, 50, 1, 100000, 8000000, 30000}, + "percent cap already used": {100000, 50000, 50000, 50, 1, 100000, 5000000, 0}, + "point worth more than Rp 1": {87550, 0, 0, 100, 100, 1000, 8755000, 875}, + "nothing left": {50000, 50000, 0, 100, 1, 100000, 0, 0}, + "overpaid": {50000, 60000, 0, 100, 1, 100000, 0, 0}, + "no balance": {50000, 0, 0, 100, 1, 0, 5000000, 0}, + "cents left over": {10000.50, 0, 0, 100, 1, 100000, 1000050, 10000}, + } { + got := computePointPaymentLimits(c.total, c.paid, c.paidWithPoints, c.percent, c.value, c.balance) + assert.Equal(t, c.wantRemaining, got.RemainingCents, name) + assert.Equal(t, c.wantMax, got.MaxPoints, name) + assert.LessOrEqual(t, got.MaxPoints*c.value*100, got.RemainingCents, "%s: never more than what is left", name) + } +} + +func TestFormatRupiah(t *testing.T) { + for n, want := range map[int64]string{0: "0", 999: "999", 1000: "1.000", 50000: "50.000", 1234567: "1.234.567"} { + assert.Equal(t, want, formatRupiah(n)) + } +} diff --git a/internal/repository/point_payment_repository.go b/internal/repository/point_payment_repository.go new file mode 100644 index 0000000..c98fdd1 --- /dev/null +++ b/internal/repository/point_payment_repository.go @@ -0,0 +1,178 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" +) + +// ErrPointPaymentOrderNotFound means the order does not exist. +var ErrPointPaymentOrderNotFound = errors.New("point payment: order not found") + +// PointPaymentOrder is what paying with EnakPoint needs to know about an order. +type PointPaymentOrder struct { + ID uuid.UUID + OrganizationID uuid.UUID + OutletID uuid.UUID + OrderNumber string + OutletName string + CustomerID *uuid.UUID + TotalAmount float64 + PaymentStatus string + IsVoid bool + CustomerIsDefault *bool + CustomerIsActive *bool +} + +// PointPaymentRepository reads and writes what paying an order with EnakPoint touches +// (docs/prd-point-coin.md F9). Unlike the order and payment repositories, every method +// joins the caller's transaction, since the payment row, the balance and the order +// must change together. +type PointPaymentRepository interface { + // GetOrder reads the order; with lock it also locks the order row for the rest of + // the transaction, so two payments of the same order queue up. + GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error) + // GetPaymentMethod returns a method's organization and type. + GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (organizationID uuid.UUID, methodType string, err error) + // TotalPaid sums the order's completed payments, as the rest of the order flow does. + TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error) + // PaidWithPoints sums the rupiah of the order's completed EnakPoint payments. + PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error) + InsertPayment(ctx context.Context, payment *entities.Payment) error + // UpdateOrderAfterPayment stores what is left to pay and marks the order paid when + // nothing is. + UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error +} + +type pointPaymentRepository struct { + db *gorm.DB +} + +func NewPointPaymentRepository(db *gorm.DB) PointPaymentRepository { + return &pointPaymentRepository{db: db} +} + +func (r *pointPaymentRepository) GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error) { + lockClause := "" + if lock { + lockClause = "FOR UPDATE OF o" + } + var rows []struct { + ID string + OrganizationID string + OutletID string + OrderNumber string + OutletName string + CustomerID *string + TotalAmount float64 + PaymentStatus string + IsVoid bool + CustomerIsDefault *bool + CustomerIsActive *bool + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id, + o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id, + o.total_amount, o.payment_status, COALESCE(o.is_void, false) AS is_void, + c.is_default AS customer_is_default, c.is_active AS customer_is_active + FROM orders o + LEFT JOIN outlets ou ON ou.id = o.outlet_id + LEFT JOIN customers c ON c.id = o.customer_id + WHERE o.id = ? + `+lockClause, orderID).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to read order: %w", err) + } + if len(rows) == 0 { + return nil, ErrPointPaymentOrderNotFound + } + row := rows[0] + order := &PointPaymentOrder{ + OrderNumber: row.OrderNumber, + OutletName: row.OutletName, + TotalAmount: row.TotalAmount, + PaymentStatus: row.PaymentStatus, + IsVoid: row.IsVoid, + CustomerIsDefault: row.CustomerIsDefault, + CustomerIsActive: row.CustomerIsActive, + } + order.ID, _ = uuid.Parse(row.ID) + order.OrganizationID, _ = uuid.Parse(row.OrganizationID) + order.OutletID, _ = uuid.Parse(row.OutletID) + if row.CustomerID != nil { + if id, err := uuid.Parse(*row.CustomerID); err == nil { + order.CustomerID = &id + } + } + return order, nil +} + +func (r *pointPaymentRepository) GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (uuid.UUID, string, error) { + var rows []struct { + OrganizationID string + Type string + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT organization_id::text AS organization_id, type FROM payment_methods WHERE id = ?`, methodID).Scan(&rows).Error + if err != nil { + return uuid.Nil, "", fmt.Errorf("failed to read payment method: %w", err) + } + if len(rows) == 0 { + return uuid.Nil, "", fmt.Errorf("payment method not found") + } + org, _ := uuid.Parse(rows[0].OrganizationID) + return org, rows[0].Type, nil +} + +func (r *pointPaymentRepository) TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error) { + var total float64 + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT COALESCE(SUM(amount), 0) FROM payments WHERE order_id = ? AND status = ?`, + orderID, entities.PaymentTransactionStatusCompleted).Scan(&total).Error + if err != nil { + return 0, fmt.Errorf("failed to sum payments: %w", err) + } + return total, nil +} + +func (r *pointPaymentRepository) PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error) { + var total float64 + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT COALESCE(SUM(p.amount), 0) FROM payments p + JOIN payment_methods pm ON pm.id = p.payment_method_id + WHERE p.order_id = ? AND p.status = ? AND pm.type = ?`, + orderID, entities.PaymentTransactionStatusCompleted, constants.PaymentMethodTypePoint).Scan(&total).Error + if err != nil { + return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err) + } + return total, nil +} + +func (r *pointPaymentRepository) InsertPayment(ctx context.Context, payment *entities.Payment) error { + if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(payment).Error; err != nil { + return fmt.Errorf("failed to create payment: %w", err) + } + return nil +} + +func (r *pointPaymentRepository) UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error { + paymentStatus := entities.PaymentStatusPartial + if completed { + paymentStatus = entities.PaymentStatusCompleted + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` + UPDATE orders SET remaining_amount = ?, payment_status = ?, + status = CASE WHEN ? THEN ? ELSE status END, updated_at = NOW() + WHERE id = ?`, + remaining, paymentStatus, completed, entities.OrderStatusCompleted, orderID).Error + if err != nil { + return fmt.Errorf("failed to update order after payment: %w", err) + } + return nil +} diff --git a/internal/router/router.go b/internal/router/router.go index 3c8e79b..23cb25c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -57,12 +57,13 @@ type Router struct { walletAdminHandler *handler.WalletAdminHandler loyaltySettingsHandler *handler.LoyaltySettingsHandler customerPinHandler *handler.CustomerPinHandler + pointPaymentHandler *handler.PointPaymentHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -111,6 +112,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator), loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService), customerPinHandler: handler.NewCustomerPinHandler(customerPinService), + pointPaymentHandler: handler.NewPointPaymentHandler(pointPaymentService), redisClient: redisClient, } } @@ -294,6 +296,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { { orders.GET("", r.orderHandler.ListOrders) orders.GET("/:id", r.orderHandler.GetOrderByID) + orders.GET("/:id/point-payment/preview", r.pointPaymentHandler.Preview) orders.POST("", r.orderHandler.CreateOrder) orders.POST("/:id/add-items", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.AddToOrder) orders.PUT("/:id", r.orderHandler.UpdateOrder) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 31dfbde..9feaf26 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -36,6 +36,7 @@ func TestAllRoutesRegister(t *testing.T) { "PUT /api/v1/marketing/loyalty-settings", "GET /api/v1/marketing/loyalty-settings/history", "POST /api/v1/customer/wallet/payment-code", + "GET /api/v1/orders/:id/point-payment/preview", "GET /api/v1/customer/pin/status", "POST /api/v1/customer/pin/otp", "POST /api/v1/customer/pin", diff --git a/internal/service/order_service.go b/internal/service/order_service.go index d7e450c..626fb24 100644 --- a/internal/service/order_service.go +++ b/internal/service/order_service.go @@ -557,7 +557,8 @@ func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymen return fmt.Errorf("payment method ID is required") } - if req.Amount <= 0 { + // A payment with EnakPoint gives points instead; its amount is computed from them. + if req.Points == nil && req.Amount <= 0 { return fmt.Errorf("payment amount must be greater than zero") } diff --git a/internal/service/point_payment_service.go b/internal/service/point_payment_service.go new file mode 100644 index 0000000..a0c609e --- /dev/null +++ b/internal/service/point_payment_service.go @@ -0,0 +1,55 @@ +package service + +import ( + "context" + "errors" + + "github.com/google/uuid" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" +) + +// PointPaymentService serves what the cashier needs before taking EnakPoint +// (docs/prd-point-coin.md F9). +type PointPaymentService interface { + Preview(ctx context.Context, apctx *appcontext.ContextInfo, orderID uuid.UUID) *contract.Response +} + +type PointPaymentServiceImpl struct { + payments *processor.PointPaymentProcessor +} + +func NewPointPaymentService(payments *processor.PointPaymentProcessor) *PointPaymentServiceImpl { + return &PointPaymentServiceImpl{payments: payments} +} + +func (s *PointPaymentServiceImpl) Preview(ctx context.Context, apctx *appcontext.ContextInfo, orderID uuid.UUID) *contract.Response { + preview, err := s.payments.Preview(ctx, apctx.OrganizationID, orderID) + if err != nil { + return PointPaymentErrorResponse(err) + } + return contract.BuildSuccessResponse(preview) +} + +// PointPaymentErrorResponse answers a refused EnakPoint payment as a bad request, PIN +// problems with their own codes, and anything else as a server error. +func PointPaymentErrorResponse(err error) *contract.Response { + var pinErr *processor.PinError + if errors.As(err, &pinErr) { + return PinErrorResponse(err) + } + code := constants.InternalServerErrorCode + switch { + case errors.Is(err, repository.ErrPointPaymentOrderNotFound): + code = constants.NotFoundErrorCode + case errors.Is(err, processor.ErrPointPaymentRejected): + code = constants.ValidationErrorCode + } + return contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(code, constants.WalletServiceEntity, err.Error()), + }) +} diff --git a/internal/transformer/order_transformer.go b/internal/transformer/order_transformer.go index 1c34626..5bd19ba 100644 --- a/internal/transformer/order_transformer.go +++ b/internal/transformer/order_transformer.go @@ -324,6 +324,8 @@ func CreatePaymentContractToModel(req *contract.CreatePaymentRequest) *models.Cr return &models.CreatePaymentRequest{ OrderID: req.OrderID, PaymentMethodID: req.PaymentMethodID, + Points: req.Points, + PaymentCode: req.PaymentCode, Amount: req.Amount, TransactionID: req.TransactionID, SplitNumber: req.SplitNumber, @@ -365,6 +367,8 @@ func PaymentModelToContract(resp *models.PaymentResponse) *contract.PaymentRespo SplitType: resp.SplitType, SplitDescription: resp.SplitDescription, RefundAmount: resp.RefundAmount, + PointsUsed: resp.PointsUsed, + PointValue: resp.PointValue, RefundReason: resp.RefundReason, RefundedAt: resp.RefundedAt, RefundedBy: resp.RefundedBy, -- 2.54.0 From 43eac0ced45116ff7f1f001531f9f8831b6b639a Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 11:49:49 +0700 Subject: [PATCH 31/47] feat(loyalty): pay own orders with EnakPoint from the app Adds POST /customer/orders/:id/pay-with-points (docs/prd-point-coin.md F9, PC-306) for the customer app and self-order. It uses the same payment path as the cashier, approved by the customer's PIN instead of a code: the session alone is not enough (K8), and a wrong PIN takes nothing and counts toward the lock. A customer can pay only their own order; any other order, and one that does not exist, answer 404 alike, so the endpoint does not reveal other customers' orders. The method is the organization's EnakPoint method, no cashier is recorded, and settling the order triggers earning through the same onOrderPaid hook as every other payment. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 5 +- internal/contract/customer_pin_contract.go | 6 +++ .../handler/customer_order_payment_handler.go | 35 +++++++++++++ internal/processor/order_processor.go | 52 +++++++++++++++++-- internal/processor/point_payment_db_test.go | 44 +++++++++++++++- internal/processor/point_payment_processor.go | 15 ++++++ .../repository/point_payment_repository.go | 18 +++++++ internal/router/router.go | 5 +- internal/router/router_test.go | 1 + .../service/customer_order_payment_service.go | 34 ++++++++++++ internal/service/order_service_table_test.go | 8 +++ 11 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 internal/handler/customer_order_payment_handler.go create mode 100644 internal/service/customer_order_payment_service.go diff --git a/internal/app/app.go b/internal/app/app.go index 23a9f90..c207899 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -158,6 +158,7 @@ func (a *App) Initialize(cfg *config.Config) error { services.loyaltySettingsService, services.customerPinService, services.pointPaymentService, + services.customerOrderPaymentService, a.redisClient, ) @@ -409,7 +410,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor orderProcessor.SetLoyalty(earningProcessor) // Pay orders with EnakPoint, approved by the customer's one-time code (docs/prd-point-coin.md F9) pointPaymentProcessor := processor.NewPointPaymentProcessor(repository.NewPointPaymentRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) - orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor) + orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor) return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), @@ -509,6 +510,7 @@ type services struct { loyaltySettingsService *service.LoyaltySettingsServiceImpl customerPinService *service.CustomerPinServiceImpl pointPaymentService *service.PointPaymentServiceImpl + customerOrderPaymentService *service.CustomerOrderPaymentServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -594,6 +596,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo), customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor), pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor), + customerOrderPaymentService: service.NewCustomerOrderPaymentService(processors.orderProcessor), } } diff --git a/internal/contract/customer_pin_contract.go b/internal/contract/customer_pin_contract.go index 8140bd3..cb550cc 100644 --- a/internal/contract/customer_pin_contract.go +++ b/internal/contract/customer_pin_contract.go @@ -31,3 +31,9 @@ type RemoveCustomerPinRequest struct { type IssuePaymentCodeRequest struct { Pin string `json:"pin" binding:"required"` } + +// PayWithPointsRequest is POST /customer/orders/:id/pay-with-points. +type PayWithPointsRequest struct { + Points int64 `json:"points" binding:"required,min=1"` + Pin string `json:"pin" binding:"required"` +} diff --git a/internal/handler/customer_order_payment_handler.go b/internal/handler/customer_order_payment_handler.go new file mode 100644 index 0000000..29f7071 --- /dev/null +++ b/internal/handler/customer_order_payment_handler.go @@ -0,0 +1,35 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" +) + +// CustomerOrderPaymentHandler serves POST /customer/orders/:id/pay-with-points +// (docs/prd-point-coin.md F9). The body holds the PIN, so it is never logged. +type CustomerOrderPaymentHandler struct { + payments service.CustomerOrderPaymentService +} + +func NewCustomerOrderPaymentHandler(payments service.CustomerOrderPaymentService) *CustomerOrderPaymentHandler { + return &CustomerOrderPaymentHandler{payments: payments} +} + +func (h *CustomerOrderPaymentHandler) PayWithPoints(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerOrderPaymentHandler::PayWithPoints") + if !ok { + return + } + orderID, ok := parseUUIDParam(c, "id", "CustomerOrderPaymentHandler::PayWithPoints") + if !ok { + return + } + var req contract.PayWithPointsRequest + if !bindPinRequest(c, &req, "CustomerOrderPaymentHandler::PayWithPoints") { + return + } + util.HandleResponse(c.Writer, c.Request, h.payments.PayWithPoints(c.Request.Context(), customerID, orderID, &req, pinRequestInfo(c)), "CustomerOrderPaymentHandler::PayWithPoints") +} diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 701633c..7d1ff50 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -21,6 +21,9 @@ type OrderProcessor interface { CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error) + // PayWithPointsInApp pays the customer's own order with EnakPoint from the app or a + // self-order, approved by their PIN (docs/prd-point-coin.md F9). + PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error) VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error @@ -114,6 +117,7 @@ type OrderProcessorImpl struct { loyalty OrderLoyalty pointPayments *PointPaymentProcessor paymentCodes paymentCodeRedeemer + pins pinVerifier } // OrderLoyalty is what the order flow tells and asks the loyalty program @@ -144,11 +148,13 @@ type paymentCodeRedeemer interface { Redeem(ctx context.Context, code string, customerID uuid.UUID) error } -// SetPointPayments enables paying with the EnakPoint method: CreatePayment hands such -// payments to pointPayments, approved by the code the customer shows (F9). -func (p *OrderProcessorImpl) SetPointPayments(pointPayments *PointPaymentProcessor, codes paymentCodeRedeemer) { +// SetPointPayments enables paying with the EnakPoint method. CreatePayment hands such +// payments to pointPayments approved by the code the customer shows at the cashier, +// and PayWithPointsInApp approved by the customer's PIN (F9). +func (p *OrderProcessorImpl) SetPointPayments(pointPayments *PointPaymentProcessor, codes paymentCodeRedeemer, pins pinVerifier) { p.pointPayments = pointPayments p.paymentCodes = codes + p.pins = pins } // createPointPayment is CreatePayment for the EnakPoint method. It never uses the @@ -1787,3 +1793,43 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Con func stringPtr(s string) *string { return &s } + +// PayWithPointsInApp pays an order with EnakPoint on the customer's own request, in the +// app or a self-order. The session alone is not enough: the customer's PIN approves +// it (K8). An order that is not the customer's own is reported as not found, so the +// endpoint does not reveal other customers' orders. +func (p *OrderProcessorImpl) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) { + if p.pointPayments == nil || p.pins == nil { + return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected) + } + organizationID, owner, err := p.pointPayments.OrderOwner(ctx, orderID) + if err != nil { + return nil, err + } + if owner == nil || *owner != customerID { + return nil, repository.ErrPointPaymentOrderNotFound + } + methodID, err := p.pointPayments.PointMethodID(ctx, organizationID) + if err != nil { + return nil, err + } + result, err := p.pointPayments.Pay(ctx, PointPaymentInput{ + OrderID: orderID, + PaymentMethodID: methodID, + Points: points, + Authorize: func(ctx context.Context, customerID uuid.UUID) error { + return p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info) + }, + }) + if err != nil { + return nil, err + } + if result.Completed { + p.onOrderPaid(ctx, orderID) + } + payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID) + if err != nil { + return nil, fmt.Errorf("failed to retrieve created payment: %w", err) + } + return mappers.PaymentEntityToResponse(payment), nil +} diff --git a/internal/processor/point_payment_db_test.go b/internal/processor/point_payment_db_test.go index cd846ac..7f72d3a 100644 --- a/internal/processor/point_payment_db_test.go +++ b/internal/processor/point_payment_db_test.go @@ -91,7 +91,7 @@ func newPointPaymentEnv(t *testing.T) *pointPaymentEnv { txManager: txm, } e.orders.SetLoyalty(NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm)) - e.orders.SetPointPayments(e.payments, e.codes) + e.orders.SetPointPayments(e.payments, e.codes, pinVerifierFake{good: "482913"}) // The outlet earns 1 EnakPoint per Rp 100 and accepts EnakPoint. s, err := settings.Outlet(context.Background(), e.outlet) @@ -332,3 +332,45 @@ func TestPointPayment_ConcurrentForOneCustomer(t *testing.T) { assert.NotEqual(t, customer, d.CustomerID, d.Check) } } + +func TestPointPayment_InApp(t *testing.T) { + e := newPointPaymentEnv(t) + owner := e.customerWith(100000) + stranger := e.customerWith(100000) + order := e.order(owner, 60000) + info := models.CustomerPinRequestInfo{} + + // Another customer cannot pay it, and is not told it exists. + _, err := e.orders.PayWithPointsInApp(e.ctx, stranger, order, 1000, "482913", info) + assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound) + _, err = e.orders.PayWithPointsInApp(e.ctx, owner, uuid.New(), 1000, "482913", info) + assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound) + + // The session alone is not enough: a wrong PIN takes nothing. + _, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 1000, "000000", info) + var pe *PinError + require.ErrorAs(t, err, &pe) + assert.Equal(t, PinErrInvalid, pe.Code) + assert.Equal(t, int64(100000), e.balance(owner)) + + // The owner pays part, then the rest, with the same rules as at the cashier. + payment, err := e.orders.PayWithPointsInApp(e.ctx, owner, order, 10000, "482913", info) + require.NoError(t, err) + assert.Equal(t, int64(10000), *payment.PointsUsed) + status, remaining := e.orderState(order) + assert.Equal(t, "partial", status) + assert.Equal(t, 50000.0, remaining) + + _, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50001, "482913", info) + assert.ErrorIs(t, err, ErrPointPaymentRejected, "not more than what is left") + _, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50000, "482913", info) + require.NoError(t, err) + status, _ = e.orderState(order) + assert.Equal(t, "completed", status) + assert.Equal(t, int64(40000), e.balance(owner)) + assert.Equal(t, int64(100000), e.balance(stranger)) + + var createdBy *string + require.NoError(t, e.db.Raw(`SELECT created_by_user::text FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT' LIMIT 1`, owner).Scan(&createdBy).Error) + assert.Nil(t, createdBy, "no cashier took an in-app payment") +} diff --git a/internal/processor/point_payment_processor.go b/internal/processor/point_payment_processor.go index 144ebf1..0e989cd 100644 --- a/internal/processor/point_payment_processor.go +++ b/internal/processor/point_payment_processor.go @@ -319,3 +319,18 @@ func formatRupiah(n int64) string { } return string(out) } + +// PointMethodID returns the organization's EnakPoint payment method. +func (p *PointPaymentProcessor) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) { + return p.repo.PointMethodID(ctx, organizationID) +} + +// OrderOwner returns the organization and customer of an order, for checking that a +// customer pays only their own order. +func (p *PointPaymentProcessor) OrderOwner(ctx context.Context, orderID uuid.UUID) (organizationID uuid.UUID, customerID *uuid.UUID, err error) { + order, err := p.repo.GetOrder(ctx, orderID, false) + if err != nil { + return uuid.Nil, nil, err + } + return order.OrganizationID, order.CustomerID, nil +} diff --git a/internal/repository/point_payment_repository.go b/internal/repository/point_payment_repository.go index c98fdd1..eb6cbfa 100644 --- a/internal/repository/point_payment_repository.go +++ b/internal/repository/point_payment_repository.go @@ -44,6 +44,8 @@ type PointPaymentRepository interface { TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error) // PaidWithPoints sums the rupiah of the order's completed EnakPoint payments. PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error) + // PointMethodID returns the organization's EnakPoint payment method. + PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) InsertPayment(ctx context.Context, payment *entities.Payment) error // UpdateOrderAfterPayment stores what is left to pay and marks the order paid when // nothing is. @@ -176,3 +178,19 @@ func (r *pointPaymentRepository) UpdateOrderAfterPayment(ctx context.Context, or } return nil } + +func (r *pointPaymentRepository) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) { + var ids []string + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table("payment_methods"). + Where("organization_id = ? AND type = ?", organizationID, constants.PaymentMethodTypePoint). + Limit(1). + Pluck("id::text", &ids).Error + if err != nil { + return uuid.Nil, fmt.Errorf("failed to find the EnakPoint payment method: %w", err) + } + if len(ids) == 0 { + return uuid.Nil, fmt.Errorf("the organization has no EnakPoint payment method") + } + return uuid.Parse(ids[0]) +} diff --git a/internal/router/router.go b/internal/router/router.go index 23cb25c..ffd18f5 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -58,12 +58,13 @@ type Router struct { loyaltySettingsHandler *handler.LoyaltySettingsHandler customerPinHandler *handler.CustomerPinHandler pointPaymentHandler *handler.PointPaymentHandler + customerOrderPaymentHandler *handler.CustomerOrderPaymentHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -113,6 +114,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService), customerPinHandler: handler.NewCustomerPinHandler(customerPinService), pointPaymentHandler: handler.NewPointPaymentHandler(pointPaymentService), + customerOrderPaymentHandler: handler.NewCustomerOrderPaymentHandler(customerOrderPaymentService), redisClient: redisClient, } } @@ -167,6 +169,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions) customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode) + customer.POST("/orders/:id/pay-with-points", r.customerOrderPaymentHandler.PayWithPoints) // PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11) customer.GET("/pin/status", r.customerPinHandler.Status) customer.POST("/pin/otp", r.customerPinHandler.RequestOtp) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 9feaf26..1baa348 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -37,6 +37,7 @@ func TestAllRoutesRegister(t *testing.T) { "GET /api/v1/marketing/loyalty-settings/history", "POST /api/v1/customer/wallet/payment-code", "GET /api/v1/orders/:id/point-payment/preview", + "POST /api/v1/customer/orders/:id/pay-with-points", "GET /api/v1/customer/pin/status", "POST /api/v1/customer/pin/otp", "POST /api/v1/customer/pin", diff --git a/internal/service/customer_order_payment_service.go b/internal/service/customer_order_payment_service.go new file mode 100644 index 0000000..b7e57c4 --- /dev/null +++ b/internal/service/customer_order_payment_service.go @@ -0,0 +1,34 @@ +package service + +import ( + "context" + + "github.com/google/uuid" + + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/transformer" +) + +// CustomerOrderPaymentService lets customers pay their own orders with EnakPoint in the +// app or a self-order (docs/prd-point-coin.md F9). +type CustomerOrderPaymentService interface { + PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response +} + +type CustomerOrderPaymentServiceImpl struct { + orders processor.OrderProcessor +} + +func NewCustomerOrderPaymentService(orders processor.OrderProcessor) *CustomerOrderPaymentServiceImpl { + return &CustomerOrderPaymentServiceImpl{orders: orders} +} + +func (s *CustomerOrderPaymentServiceImpl) PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response { + payment, err := s.orders.PayWithPointsInApp(ctx, customerID, orderID, req.Points, req.Pin, info) + if err != nil { + return PointPaymentErrorResponse(err) + } + return contract.BuildSuccessResponse(transformer.PaymentModelToContract(payment)) +} diff --git a/internal/service/order_service_table_test.go b/internal/service/order_service_table_test.go index 66fd43b..adbc497 100644 --- a/internal/service/order_service_table_test.go +++ b/internal/service/order_service_table_test.go @@ -42,6 +42,14 @@ func (m *MockOrderProcessor) UpdateOrder(ctx context.Context, id uuid.UUID, req return args.Get(0).(*models.OrderResponse), args.Error(1) } +func (m *MockOrderProcessor) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) { + args := m.Called(ctx, customerID, orderID, points, pin, info) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.PaymentResponse), args.Error(1) +} + func (m *MockOrderProcessor) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) { args := m.Called(ctx, id) if args.Get(0) == nil { -- 2.54.0 From d7138b8f87239b0741a3091e3136626ee0b28aef Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 11:55:30 +0700 Subject: [PATCH 32/47] feat(loyalty): refund EnakPoint payments as EnakPoint only Adds refunds of EnakPoint payments (docs/prd-point-coin.md F9, K7, Q13, PC-307). After a void or refund, onOrderRefunded now returns EnakPoint before taking earning back. For each EnakPoint payment of the order it returns everything on a void, and floor(refunded rupiah / the frozen point_value) when the payment itself was refunded, so a later change of the point value does not change how many come back and a remainder below one EnakPoint is lost. It never returns more than the payment used, and only what has not come back yet, so repeating is safe. PAYMENT_REFUND rows point at the PAYMENT they reverse, and the EnakPoint go back into lots with the expiry of the lots they were taken from, longest-lasting first (the 7-day extension waits on note N4). RefundOrder, which hands money back in cash or another method, is now limited to what was paid with other methods; the EnakPoint part has to be refunded through its own payment. That answers 400. Fixes earning reversal from PC-204: a refund of the EnakPoint part raised orders.refund_amount and so took earning back, although that part never earned. It is now left out of the refund the reversal uses. Co-Authored-By: Claude Opus 5.5 --- internal/handler/order_handler.go | 5 + internal/processor/order_processor.go | 52 +++++- internal/processor/point_payment_refund.go | 157 ++++++++++++++++++ internal/processor/point_refund_db_test.go | 151 +++++++++++++++++ internal/repository/earning_repository.go | 8 +- .../repository/point_payment_repository.go | 143 ++++++++++++++++ 6 files changed, 512 insertions(+), 4 deletions(-) create mode 100644 internal/processor/point_payment_refund.go create mode 100644 internal/processor/point_refund_db_test.go diff --git a/internal/handler/order_handler.go b/internal/handler/order_handler.go index bfcab38..df26367 100644 --- a/internal/handler/order_handler.go +++ b/internal/handler/order_handler.go @@ -205,6 +205,11 @@ func (h *OrderHandler) RefundOrder(c *gin.Context) { } if err := h.orderService.RefundOrder(ctx, id, modelReq, userID); err != nil { + // Refusing to hand EnakPoint back as cash is a bad request, not a server fault. + if errors.Is(err, processor.ErrPointPaymentRejected) { + util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::RefundOrder") + return + } util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::RefundOrder", err.Error())}), "OrderHandler::RefundOrder") return } diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 7d1ff50..bea796c 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -212,10 +212,16 @@ func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID) // after their writes have committed, with the same guarantees as onOrderPaid: it can // never block or fail the void or refund. func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) { - if p.loyalty == nil { - return + ctx = repository.DetachTransaction(context.WithoutCancel(ctx)) + // EnakPoint paid on the order comes back first: the customer is owed it (F9). + if p.pointPayments != nil { + if _, err := p.pointPayments.RefundForOrder(ctx, orderID); err != nil { + logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::onOrderRefunded -> failed to return EnakPoint; calling it again is safe") + } + } + if p.loyalty != nil { + p.loyalty.OnOrderRefunded(ctx, orderID) } - p.loyalty.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID) } // attachEarnings fills points_earned and coins_earned (F3). A failure is logged and @@ -860,6 +866,18 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req reason = *req.Reason } + // An order refund is handed back in cash or another method, so it cannot cover what + // was paid with EnakPoint (K7). Checked before anything is written. + if p.pointPayments != nil { + planned, err := p.plannedOrderRefund(ctx, id, req) + if err != nil { + return err + } + if err := p.pointPayments.EnsureOrderRefundAllowed(ctx, id, planned); err != nil { + return err + } + } + // Process refund based on request type if req.RefundAmount != nil { // Full or partial refund by amount @@ -1833,3 +1851,31 @@ func (p *OrderProcessorImpl) PayWithPointsInApp(ctx context.Context, customerID, } return mappers.PaymentEntityToResponse(payment), nil } + +// plannedOrderRefund is the total RefundOrder is about to hand back, computed the same +// way it will be, without writing anything. +func (p *OrderProcessorImpl) plannedOrderRefund(ctx context.Context, orderID uuid.UUID, req *models.RefundOrderRequest) (float64, error) { + if req.RefundAmount != nil { + return *req.RefundAmount, nil + } + var total float64 + for _, itemRefund := range req.OrderItems { + if itemRefund.RefundAmount != nil { + total += *itemRefund.RefundAmount + continue + } + orderItem, err := p.orderItemRepo.GetByID(ctx, itemRefund.OrderItemID) + if err != nil { + return 0, fmt.Errorf("order item not found: %w", err) + } + if orderItem.OrderID != orderID { + return 0, fmt.Errorf("order item does not belong to this order") + } + quantity := itemRefund.RefundQuantity + if quantity == 0 { + quantity = orderItem.Quantity + } + total += entities.RoundMoney(orderItem.BillableQuantityFor(quantity) * orderItem.UnitPrice) + } + return total, nil +} diff --git a/internal/processor/point_payment_refund.go b/internal/processor/point_payment_refund.go new file mode 100644 index 0000000..e869795 --- /dev/null +++ b/internal/processor/point_payment_refund.go @@ -0,0 +1,157 @@ +package processor + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/repository" +) + +// RefundForOrder gives EnakPoint back for the order's EnakPoint payments, as far as the +// order has been voided or those payments refunded (docs/prd-point-coin.md F9, K7): +// +// - void: every EnakPoint used on the order; +// - a refunded EnakPoint payment: floor(refunded rupiah / the frozen point value), +// so a later change of the point value does not change how many come back, and a +// rupiah remainder below one EnakPoint is lost (Q13). +// +// Never more than the payment used, and only what has not come back yet, so it can be +// called again safely. Returned EnakPoint go back into lots with the expiry of the lots +// they were taken from (the 7-day extension waits on note N4). It returns how many came +// back in total. +func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid.UUID) (int64, error) { + order, err := p.repo.GetOrder(ctx, orderID, false) + if err != nil { + return 0, err + } + payments, err := p.repo.ListPointPayments(ctx, orderID) + if err != nil { + return 0, err + } + + var returned int64 + for _, payment := range payments { + if payment.LedgerID == nil || payment.CustomerID == nil { + continue + } + target := pointRefundTarget(order.IsVoid, payment) + if target == 0 { + continue + } + var n int64 + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + if err := p.wallet.LockWallet(ctx, *payment.CustomerID); err != nil { + return err + } + allocations, err := p.repo.PaymentAllocations(ctx, *payment.LedgerID) + if err != nil { + return err + } + refunded, err := p.repo.RefundedByOriginLot(ctx, *payment.LedgerID) + if err != nil { + return err + } + var already int64 + for _, amount := range refunded { + already += amount + } + toReturn := target - already + if toReturn <= 0 { + return nil + } + + // Fill the lots the payment took from, each up to what it gave. + var lots []WalletLotInput + left := toReturn + for _, a := range allocations { + if left == 0 { + break + } + room := a.Amount - refunded[a.LotID] + if room <= 0 { + continue + } + take := min(room, left) + left -= take + lotID := a.LotID + lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID}) + } + toReturn -= left + + ledgerID := *payment.LedgerID + if _, err := p.wallet.Credit(ctx, WalletCreditInput{ + WalletEntry: WalletEntry{ + CustomerID: *payment.CustomerID, + Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypePaymentRefund, + Amount: toReturn, + ReferenceType: constants.WalletRefTypePayment, + ReferenceID: payment.PaymentID, + ReversesTransactionID: &ledgerID, + OutletID: payment.OutletID, + Description: pointRefundDescription(order), + Metadata: entities.Metadata{"point_value": payment.PointValue, "target": target, "void": order.IsVoid}, + IdempotencyKey: fmt.Sprintf("payment-refund:%s:%d", payment.PaymentID, target), + }, + Lots: lots, + }); err != nil { + return err + } + n = toReturn + return nil + }) + if err != nil { + return returned, fmt.Errorf("refunding EnakPoint payment %s: %w", payment.PaymentID, err) + } + returned += n + } + return returned, nil +} + +// EnsureOrderRefundAllowed refuses an order-level refund that would hand back, in cash +// or another method, what was paid with EnakPoint (K7). The EnakPoint part is refunded +// through its own payment, and comes back as EnakPoint. +func (p *PointPaymentProcessor) EnsureOrderRefundAllowed(ctx context.Context, orderID uuid.UUID, amount float64) error { + refundable, err := p.repo.RefundableByOtherMethods(ctx, orderID) + if err != nil { + return err + } + paidWithPoints, err := p.repo.PaidWithPoints(ctx, orderID) + if err != nil { + return err + } + if paidWithPoints == 0 { + return nil + } + if toCents(amount) > toCents(refundable) { + return fmt.Errorf("%w: at most Rp %s can be refunded this way; the part paid with EnakPoint is refunded through its EnakPoint payment and returns as EnakPoint", + ErrPointPaymentRejected, formatRupiah(toCents(refundable)/100)) + } + return nil +} + +func pointRefundTarget(orderVoid bool, payment repository.PointPaymentRow) int64 { + if orderVoid { + return payment.PointsUsed + } + if payment.Status != string(entities.PaymentTransactionStatusRefunded) { + return 0 + } + valueCents := toCents(payment.PointValue) + if valueCents <= 0 { + return 0 + } + return min(payment.PointsUsed, toCents(payment.RefundAmount)/valueCents) +} + +func pointRefundDescription(order *repository.PointPaymentOrder) string { + description := "Pengembalian #" + order.OrderNumber + if order.OutletName != "" { + description += " di " + order.OutletName + } + return truncateRunes(description, walletDescriptionLimit) +} diff --git a/internal/processor/point_refund_db_test.go b/internal/processor/point_refund_db_test.go new file mode 100644 index 0000000..f6a5956 --- /dev/null +++ b/internal/processor/point_refund_db_test.go @@ -0,0 +1,151 @@ +package processor + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +func (e *pointPaymentEnv) sum(query string, args ...any) int64 { + e.t.Helper() + var n int64 + require.NoError(e.t, e.db.Raw(query, args...).Scan(&n).Error) + return n +} + +func (e *pointPaymentEnv) returned(order uuid.UUID) int64 { + return e.sum(`SELECT COALESCE(SUM(t.amount), 0) FROM wallet_transactions t + JOIN payments p ON p.id = t.reference_id + WHERE p.order_id = ? AND t.type = 'PAYMENT_REFUND'`, order) +} + +func (e *pointPaymentEnv) assertReconciled(customers ...uuid.UUID) { + e.t.Helper() + found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000) + require.NoError(e.t, err) + for _, d := range found { + for _, c := range customers { + assert.NotEqual(e.t, c, d.CustomerID, d.Check) + } + } +} + +func TestPointRefund_VoidReturnsEverythingToItsExpiry(t *testing.T) { + e := newPointPaymentEnv(t) + customer := e.customerWith(0) + expires := time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second) + require.NoError(t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error { + _, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{ + WalletEntry: WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, + Amount: 40000, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}, + Lots: []WalletLotInput{{Amount: 40000, ExpiresAt: &expires}}, + }) + return err + })) + + // Part of the order paid with EnakPoint, then the order is voided. + order := e.order(customer, 50000) + _, err := e.payPoints(order, 30000, e.code(customer)) + require.NoError(t, err) + assert.Equal(t, int64(10000), e.balance(customer)) + require.NoError(t, e.orders.VoidOrder(e.ctx, &models.VoidOrderRequest{OrderID: order, Type: "ALL", Reason: "batal"}, e.cashier)) + + assert.Equal(t, int64(30000), e.returned(order)) + assert.Equal(t, int64(40000), e.balance(customer)) + var expiry time.Time + require.NoError(t, e.db.Raw(`SELECT l.expires_at FROM wallet_lots l JOIN wallet_transactions t ON t.id = l.source_transaction_id + WHERE t.customer_id = ? AND t.type = 'PAYMENT_REFUND'`, customer).Scan(&expiry).Error) + assert.WithinDuration(t, expires, expiry, time.Second, "returned EnakPoint keep the expiry they had") + + // Calling it again returns nothing more. + e.orders.onOrderRefunded(e.ctx, order) + assert.Equal(t, int64(30000), e.returned(order)) + e.assertReconciled(customer) +} + +func TestPointRefund_PartialRefundFloors(t *testing.T) { + e := newPointPaymentEnv(t) + customer := e.customerWith(100000) + order := e.order(customer, 50000) + payment, err := e.payPoints(order, 50000, e.code(customer)) + require.NoError(t, err) + + // Rp 12.345,67 back at Rp 1 a point: 12.345 EnakPoint; the 67 sen are lost (Q13). + require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 12345.67, "sebagian", e.cashier)) + assert.Equal(t, int64(12345), e.returned(order)) + assert.Equal(t, int64(50000+12345), e.balance(customer)) + e.assertReconciled(customer) +} + +func TestPointRefund_UsesTheFrozenValue(t *testing.T) { + e := newPointPaymentEnv(t) + settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db)) + setValue := func(v int64) { + s, err := settings.Organization(context.Background(), e.org) + require.NoError(t, err) + s.PointValue = v + _, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s) + require.NoError(t, err) + } + setValue(100) + + customer := e.customerWith(1000) + order := e.order(customer, 50000) + payment, err := e.payPoints(order, 500, e.code(customer)) + require.NoError(t, err) + assert.Equal(t, 50000.0, payment.Amount, "500 × Rp 100") + + // The value changes before the refund; the customer still gets back what they used. + setValue(250) + require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 50000, "semua", e.cashier)) + assert.Equal(t, int64(500), e.returned(order), "50.000 / the frozen Rp 100, not the new Rp 250") + assert.Equal(t, int64(1000), e.balance(customer)) +} + +func TestPointRefund_NoCashForTheEnakPointPart(t *testing.T) { + e := newPointPaymentEnv(t) + customer := e.customerWith(100000) + order := e.order(customer, 50000) + _, err := e.payPoints(order, 20000, e.code(customer)) + require.NoError(t, err) + _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 30000}) + require.NoError(t, err) + + amount := 40000.0 + err = e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier) + assert.ErrorIs(t, err, ErrPointPaymentRejected, "Rp 40.000 in cash would include EnakPoint") + var refunded float64 + require.NoError(t, e.db.Raw(`SELECT refund_amount FROM orders WHERE id = ?`, order).Scan(&refunded).Error) + assert.Zero(t, refunded, "nothing was written") + + amount = 30000 + require.NoError(t, e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier), "the cash part can be refunded") + assert.Zero(t, e.returned(order), "and no EnakPoint came back for it") +} + +// Giving back the EnakPoint part does not take earning back: that part never earned. +func TestPointRefund_DoesNotReverseEarning(t *testing.T) { + e := newPointPaymentEnv(t) + customer := e.customerWith(100000) + order := e.order(customer, 87500) + pointPayment, err := e.payPoints(order, 20000, e.code(customer)) + require.NoError(t, err) + _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500}) + require.NoError(t, err) + earned := e.sum(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order) + require.Equal(t, int64(675), earned) + + require.NoError(t, e.orders.RefundPayment(e.ctx, pointPayment.ID, 20000, "kembali", e.cashier)) + assert.Equal(t, int64(20000), e.returned(order)) + reversed := e.sum(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL'`, order) + assert.Zero(t, reversed, "the EnakPoint part never earned, so giving it back takes nothing") + e.assertReconciled(customer) +} diff --git a/internal/repository/earning_repository.go b/internal/repository/earning_repository.go index 7b31af3..81d3711 100644 --- a/internal/repository/earning_repository.go +++ b/internal/repository/earning_repository.go @@ -92,7 +92,13 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id, o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id, o.subtotal, COALESCE(o.discount_amount, 0) AS discount_amount, o.payment_status, - COALESCE(o.is_void, false) AS is_void, COALESCE(o.refund_amount, 0) AS refund_amount, + COALESCE(o.is_void, false) AS is_void, + -- Refunds of EnakPoint payments are left out: that part never earned (Q10), + -- so giving it back must not take earning back. + COALESCE(o.refund_amount, 0) - COALESCE(( + SELECT SUM(COALESCE(p.refund_amount, 0)) FROM payments p + JOIN payment_methods pm ON pm.id = p.payment_method_id + WHERE p.order_id = o.id AND pm.type = 'point'), 0) AS refund_amount, c.is_default AS customer_is_default, c.is_active AS customer_is_active FROM orders o LEFT JOIN outlets ou ON ou.id = o.outlet_id diff --git a/internal/repository/point_payment_repository.go b/internal/repository/point_payment_repository.go index eb6cbfa..43508bf 100644 --- a/internal/repository/point_payment_repository.go +++ b/internal/repository/point_payment_repository.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/google/uuid" "gorm.io/gorm" @@ -50,6 +51,17 @@ type PointPaymentRepository interface { // UpdateOrderAfterPayment stores what is left to pay and marks the order paid when // nothing is. UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error + + // ListPointPayments returns the order's EnakPoint payments with their PAYMENT rows. + ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error) + // PaymentAllocations returns the lots a PAYMENT row took from, longest-lasting first. + PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error) + // RefundedByOriginLot sums, per original lot, what PAYMENT_REFUND rows have already + // returned for a PAYMENT row. + RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error) + // RefundableByOtherMethods is what the order's non-EnakPoint payments can still give + // back: paid minus already refunded. + RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error) } type pointPaymentRepository struct { @@ -194,3 +206,134 @@ func (r *pointPaymentRepository) PointMethodID(ctx context.Context, organization } return uuid.Parse(ids[0]) } + +// PointPaymentRow is one EnakPoint payment of an order, for refunding it. +type PointPaymentRow struct { + PaymentID uuid.UUID + Status string + PointsUsed int64 + PointValue float64 + RefundAmount float64 + // The PAYMENT ledger row that took the balance; nil if none was written. + LedgerID *uuid.UUID + CustomerID *uuid.UUID + OutletID *uuid.UUID +} + +// PointPaymentAllocation is how much a PAYMENT took from one lot, with that lot's +// expiry, so a refund can return it to the same expiry. +type PointPaymentAllocation struct { + LotID uuid.UUID + Amount int64 + ExpiresAt *time.Time +} + +func (r *pointPaymentRepository) ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error) { + var rows []struct { + PaymentID string + Status string + PointsUsed int64 + PointValue float64 + RefundAmount float64 + LedgerID *string + CustomerID *string + OutletID *string + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT p.id::text AS payment_id, p.status, p.points_used, p.point_value, + COALESCE(p.refund_amount, 0) AS refund_amount, + t.id::text AS ledger_id, t.customer_id::text AS customer_id, t.outlet_id::text AS outlet_id + FROM payments p + JOIN payment_methods pm ON pm.id = p.payment_method_id + LEFT JOIN wallet_transactions t ON t.reference_type = ? AND t.reference_id = p.id AND t.type = ? + WHERE p.order_id = ? AND pm.type = ? AND p.points_used IS NOT NULL + ORDER BY p.created_at, p.id`, + constants.WalletRefTypePayment, constants.WalletTxTypePayment, orderID, constants.PaymentMethodTypePoint). + Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to list EnakPoint payments: %w", err) + } + out := make([]PointPaymentRow, 0, len(rows)) + for _, row := range rows { + p := PointPaymentRow{Status: row.Status, PointsUsed: row.PointsUsed, PointValue: row.PointValue, RefundAmount: row.RefundAmount} + p.PaymentID, _ = uuid.Parse(row.PaymentID) + p.LedgerID = parseOptionalUUID(row.LedgerID) + p.CustomerID = parseOptionalUUID(row.CustomerID) + p.OutletID = parseOptionalUUID(row.OutletID) + out = append(out, p) + } + return out, nil +} + +func (r *pointPaymentRepository) PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error) { + var rows []struct { + LotID string + Amount int64 + ExpiresAt *time.Time + } + // Longest-lasting first: a partial refund gives back the balance that keeps longest. + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT a.lot_id::text AS lot_id, a.amount, l.expires_at + FROM wallet_lot_allocations a JOIN wallet_lots l ON l.id = a.lot_id + WHERE a.transaction_id = ? + ORDER BY l.expires_at DESC NULLS FIRST, l.created_at DESC, l.id`, ledgerID).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to list payment allocations: %w", err) + } + out := make([]PointPaymentAllocation, 0, len(rows)) + for _, row := range rows { + id, err := uuid.Parse(row.LotID) + if err != nil { + continue + } + out = append(out, PointPaymentAllocation{LotID: id, Amount: row.Amount, ExpiresAt: row.ExpiresAt}) + } + return out, nil +} + +func (r *pointPaymentRepository) RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error) { + var rows []struct { + OriginLotID string + Amount int64 + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT l.origin_lot_id::text AS origin_lot_id, SUM(l.original_amount) AS amount + FROM wallet_transactions t JOIN wallet_lots l ON l.source_transaction_id = t.id + WHERE t.reverses_transaction_id = ? AND t.type = ? AND l.origin_lot_id IS NOT NULL + GROUP BY l.origin_lot_id`, ledgerID, constants.WalletTxTypePaymentRefund).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to sum payment refunds: %w", err) + } + out := make(map[uuid.UUID]int64, len(rows)) + for _, row := range rows { + if id, err := uuid.Parse(row.OriginLotID); err == nil { + out[id] = row.Amount + } + } + return out, nil +} + +func (r *pointPaymentRepository) RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error) { + var total float64 + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT COALESCE(SUM(p.amount - COALESCE(p.refund_amount, 0)), 0) + FROM payments p JOIN payment_methods pm ON pm.id = p.payment_method_id + WHERE p.order_id = ? AND pm.type <> ? AND p.status IN (?, ?)`, + orderID, constants.PaymentMethodTypePoint, + entities.PaymentTransactionStatusCompleted, entities.PaymentTransactionStatusRefunded).Scan(&total).Error + if err != nil { + return 0, fmt.Errorf("failed to sum refundable payments: %w", err) + } + return total, nil +} + +func parseOptionalUUID(s *string) *uuid.UUID { + if s == nil { + return nil + } + id, err := uuid.Parse(*s) + if err != nil { + return nil + } + return &id +} -- 2.54.0 From 0b52edf84e9a6c92b144b37e7636f5969cec44ea Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 11:59:33 +0700 Subject: [PATCH 33/47] feat(loyalty): report EnakPoint apart from money received Keeps EnakPoint out of the money in on the payment method analytics (docs/prd-point-coin.md F9, K7, PC-308), the one report that sums payments; the daily transaction and profit-loss PDFs do not break payments down by method. summary.total_amount is now only money actually received. EnakPoint stays listed as its own method, with points_used, and the summary adds point_amount, points_used and total_with_points. Each method row says whether it counts_as_cash_in, and the shares are of the money received, 0 for EnakPoint. The average order value still includes what EnakPoint paid, since that is part of what the orders were worth. How EnakPoint is booked waits on note N2. Co-Authored-By: Claude Opus 5.5 --- internal/contract/analytics_contract.go | 6 +++ internal/entities/analytics.go | 20 +++++----- internal/models/analytics.go | 27 +++++++++---- internal/processor/analytics_processor.go | 29 ++++++++++---- .../processor/analytics_processor_test.go | 38 ++++++++++++++++++- internal/processor/point_payment_db_test.go | 25 ++++++++++++ internal/repository/analytics_repository.go | 19 +++++----- internal/transformer/analytics_transformer.go | 7 ++++ 8 files changed, 136 insertions(+), 35 deletions(-) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 1d2045f..3b48206 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -28,7 +28,11 @@ type PaymentMethodAnalyticsResponse struct { // PaymentMethodSummary represents the summary of payment method analytics type PaymentMethodSummary struct { + // Money actually received; EnakPoint is reported apart (docs/prd-point-coin.md F9). TotalAmount float64 `json:"total_amount"` + PointAmount float64 `json:"point_amount"` + PointsUsed int64 `json:"points_used"` + TotalWithPoints float64 `json:"total_with_points"` TotalOrders int64 `json:"total_orders"` TotalPayments int64 `json:"total_payments"` AverageOrderValue float64 `json:"average_order_value"` @@ -42,6 +46,8 @@ type PaymentMethodAnalyticsData struct { OrderCount int64 `json:"order_count"` PaymentCount int64 `json:"payment_count"` Percentage float64 `json:"percentage"` + PointsUsed int64 `json:"points_used"` + CountsAsCashIn bool `json:"counts_as_cash_in"` } type SalesAnalyticsRequest struct { diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 87b9644..8446e86 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -14,6 +14,8 @@ type PaymentMethodAnalytics struct { TotalAmount float64 `json:"total_amount"` OrderCount int64 `json:"order_count"` PaymentCount int64 `json:"payment_count"` + // EnakPoint used, for the EnakPoint method. + PointsUsed int64 `json:"points_used"` } // SalesAnalytics represents sales analytics data @@ -173,15 +175,15 @@ type BudgetCutOffWeek struct { // DashboardOverview represents dashboard overview data type DashboardOverview struct { - TotalSales float64 `json:"total_sales"` - TotalOrders int64 `json:"total_orders"` - AverageOrderValue float64 `json:"average_order_value"` - TotalCustomers int64 `json:"total_customers"` - VoidedOrders int64 `json:"voided_orders"` - RefundedOrders int64 `json:"refunded_orders"` - TotalItemSold int64 `json:"total_item_sold"` - TotalLowStock int64 `json:"total_low_stock"` - TotalProductActive int64 `json:"total_product_active"` + TotalSales float64 `json:"total_sales"` + TotalOrders int64 `json:"total_orders"` + AverageOrderValue float64 `json:"average_order_value"` + TotalCustomers int64 `json:"total_customers"` + VoidedOrders int64 `json:"voided_orders"` + RefundedOrders int64 `json:"refunded_orders"` + TotalItemSold int64 `json:"total_item_sold"` + TotalLowStock int64 `json:"total_low_stock"` + TotalProductActive int64 `json:"total_product_active"` } type ProfitLossAnalytics struct { diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 109a65b..141dac2 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -33,7 +33,14 @@ type PaymentMethodAnalyticsResponse struct { // PaymentMethodSummary represents the summary of payment method analytics type PaymentMethodSummary struct { - TotalAmount float64 `json:"total_amount"` + // Money actually received. EnakPoint is not money in (docs/prd-point-coin.md F9, + // K7) and is reported apart; its accounting treatment waits on note N2. + TotalAmount float64 `json:"total_amount"` + // Rupiah paid with EnakPoint, and how many EnakPoint that was. + PointAmount float64 `json:"point_amount"` + PointsUsed int64 `json:"points_used"` + // TotalAmount plus PointAmount: the value of the orders paid. + TotalWithPoints float64 `json:"total_with_points"` TotalOrders int64 `json:"total_orders"` TotalPayments int64 `json:"total_payments"` AverageOrderValue float64 `json:"average_order_value"` @@ -47,7 +54,11 @@ type PaymentMethodAnalyticsData struct { TotalAmount float64 `json:"total_amount"` OrderCount int64 `json:"order_count"` PaymentCount int64 `json:"payment_count"` - Percentage float64 `json:"percentage"` + // Share of the money received; 0 for EnakPoint, which is not money in. + Percentage float64 `json:"percentage"` + PointsUsed int64 `json:"points_used"` + // False for EnakPoint. + CountsAsCashIn bool `json:"counts_as_cash_in"` } // SalesAnalyticsRequest represents the request for sales analytics @@ -464,12 +475,12 @@ type ProfitLossAnalyticsResponse struct { } type ProfitLossPurchasing struct { - TodayTotal float64 `json:"today_total"` - MtdTotal float64 `json:"mtd_total"` - TodayRawMaterial float64 `json:"today_raw_material"` - MtdRawMaterial float64 `json:"mtd_raw_material"` - TodayExpense float64 `json:"today_expense"` - MtdExpense float64 `json:"mtd_expense"` + TodayTotal float64 `json:"today_total"` + MtdTotal float64 `json:"mtd_total"` + TodayRawMaterial float64 `json:"today_raw_material"` + MtdRawMaterial float64 `json:"mtd_raw_material"` + TodayExpense float64 `json:"today_expense"` + MtdExpense float64 `json:"mtd_expense"` Items []ProfitLossPurchasingItem `json:"items"` } diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 1297e90..9764551 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -63,27 +63,37 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, return nil, fmt.Errorf("failed to get payment method analytics: %w", err) } - var totalAmount float64 + // EnakPoint is not money in (docs/prd-point-coin.md F9, K7): it is listed as its own + // method but left out of the money received and of the shares. How it is booked + // waits on note N2. + var cashAmount, pointAmount float64 + var pointsUsed int64 var totalOrders int64 var totalPayments int64 for _, data := range analyticsData { - totalAmount += data.TotalAmount + if data.PaymentMethodType == string(constants.PaymentMethodTypePoint) { + pointAmount += data.TotalAmount + pointsUsed += data.PointsUsed + } else { + cashAmount += data.TotalAmount + } totalOrders += data.OrderCount totalPayments += data.PaymentCount } + // The value of an order includes what EnakPoint paid, so the average does too. var averageOrderValue float64 if totalOrders > 0 { - averageOrderValue = totalAmount / float64(totalOrders) + averageOrderValue = (cashAmount + pointAmount) / float64(totalOrders) } - // Calculate percentages var resultData []models.PaymentMethodAnalyticsData for _, data := range analyticsData { + cashIn := data.PaymentMethodType != string(constants.PaymentMethodTypePoint) var percentage float64 - if totalAmount > 0 { - percentage = (data.TotalAmount / totalAmount) * 100 + if cashIn && cashAmount > 0 { + percentage = (data.TotalAmount / cashAmount) * 100 } resultData = append(resultData, models.PaymentMethodAnalyticsData{ @@ -94,11 +104,16 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, OrderCount: data.OrderCount, PaymentCount: data.PaymentCount, Percentage: percentage, + PointsUsed: data.PointsUsed, + CountsAsCashIn: cashIn, }) } summary := models.PaymentMethodSummary{ - TotalAmount: totalAmount, + TotalAmount: cashAmount, + PointAmount: pointAmount, + PointsUsed: pointsUsed, + TotalWithPoints: cashAmount + pointAmount, TotalOrders: totalOrders, TotalPayments: totalPayments, AverageOrderValue: averageOrderValue, diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 7ae2d0e..33f9f79 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -10,6 +10,7 @@ import ( "apskel-pos-be/internal/models" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,10 +25,11 @@ type analyticsRepositoryStub struct { exclusiveSummaryCalls int exclusiveSummaryFrom []time.Time exclusiveSummaryTo []time.Time + paymentMethods []*entities.PaymentMethodAnalytics } -func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { - return nil, nil +func (s analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { + return s.paymentMethods, nil } func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) ([]*entities.SalesAnalytics, error) { @@ -570,3 +572,35 @@ func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *t require.Len(t, result.DailySummary, 1) require.Len(t, result.DailyTransactions, 2) } + +// EnakPoint is listed as its own method but is not money in (F9, K7). +func TestPaymentMethodAnalytics_EnakPointIsNotCashIn(t *testing.T) { + repo := &analyticsRepositoryStub{paymentMethods: []*entities.PaymentMethodAnalytics{ + {PaymentMethodName: "Tunai", PaymentMethodType: "cash", TotalAmount: 70000, OrderCount: 2, PaymentCount: 2}, + {PaymentMethodName: "Kartu", PaymentMethodType: "card", TotalAmount: 20000, OrderCount: 1, PaymentCount: 1}, + {PaymentMethodName: "EnakPoint", PaymentMethodType: "point", TotalAmount: 30000, OrderCount: 1, PaymentCount: 1, PointsUsed: 30000}, + }} + p := NewAnalyticsProcessorImpl(repo, nil) + + got, err := p.GetPaymentMethodAnalytics(context.Background(), &models.PaymentMethodAnalyticsRequest{ + OrganizationID: uuid.New(), DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now(), + }) + require.NoError(t, err) + assert.Equal(t, 90000.0, got.Summary.TotalAmount, "money in leaves EnakPoint out") + assert.Equal(t, 30000.0, got.Summary.PointAmount) + assert.Equal(t, int64(30000), got.Summary.PointsUsed) + assert.Equal(t, 120000.0, got.Summary.TotalWithPoints) + assert.Equal(t, int64(4), got.Summary.TotalOrders) + assert.Equal(t, 30000.0, got.Summary.AverageOrderValue, "the value of an order includes what EnakPoint paid") + + byType := map[string]models.PaymentMethodAnalyticsData{} + for _, d := range got.Data { + byType[d.PaymentMethodType] = d + } + assert.True(t, byType["cash"].CountsAsCashIn) + assert.False(t, byType["point"].CountsAsCashIn) + assert.InDelta(t, 77.78, byType["cash"].Percentage, 0.01, "shares are of the money received") + assert.InDelta(t, 22.22, byType["card"].Percentage, 0.01) + assert.Zero(t, byType["point"].Percentage) + assert.Equal(t, int64(30000), byType["point"].PointsUsed) +} diff --git a/internal/processor/point_payment_db_test.go b/internal/processor/point_payment_db_test.go index 7f72d3a..3ab8c2f 100644 --- a/internal/processor/point_payment_db_test.go +++ b/internal/processor/point_payment_db_test.go @@ -5,6 +5,7 @@ import ( "os" "sync" "testing" + "time" "github.com/alicebob/miniredis/v2" "github.com/google/uuid" @@ -374,3 +375,27 @@ func TestPointPayment_InApp(t *testing.T) { require.NoError(t, e.db.Raw(`SELECT created_by_user::text FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT' LIMIT 1`, owner).Scan(&createdBy).Error) assert.Nil(t, createdBy, "no cashier took an in-app payment") } + +// The payment method report counts only money actually received as money in; the +// EnakPoint part is listed apart (F9). +func TestPointPayment_ReportKeepsEnakPointOutOfCashIn(t *testing.T) { + e := newPointPaymentEnv(t) + customer := e.customerWith(100000) + order := e.order(customer, 87500) + _, err := e.payPoints(order, 20000, e.code(customer)) + require.NoError(t, err) + _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500}) + require.NoError(t, err) + + report, err := NewAnalyticsProcessorImpl(repository.NewAnalyticsRepositoryImpl(e.db), nil).GetPaymentMethodAnalytics(context.Background(), + &models.PaymentMethodAnalyticsRequest{OrganizationID: e.org, DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now().Add(time.Hour)}) + require.NoError(t, err) + assert.Equal(t, 67500.0, report.Summary.TotalAmount, "money in is the cash, not the order total") + assert.Equal(t, 20000.0, report.Summary.PointAmount) + assert.Equal(t, int64(20000), report.Summary.PointsUsed) + assert.Equal(t, 87500.0, report.Summary.TotalWithPoints) + require.Len(t, report.Data, 2) + for _, d := range report.Data { + assert.Equal(t, d.PaymentMethodType != "point", d.CountsAsCashIn, d.PaymentMethodName) + } +} diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 1696973..a4fddcb 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -99,7 +99,8 @@ func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, pm.type as payment_method_type, COALESCE(SUM(p.amount), 0) as total_amount, COUNT(DISTINCT p.order_id) as order_count, - COUNT(p.id) as payment_count + COUNT(p.id) as payment_count, + COALESCE(SUM(p.points_used), 0) as points_used `). Joins("JOIN payment_methods pm ON p.payment_method_id = pm.id"). Joins("JOIN orders o ON p.order_id = o.id"). @@ -478,7 +479,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ COALESCE(SUM(oi.weight), 0) as weight_sold, COALESCE(SUM(oi.total_price), 0) as revenue, CASE - WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(` + billableQty + `) + WHEN SUM(`+billableQty+`) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(`+billableQty+`) ELSE 0 END as average_price, COUNT(DISTINCT oi.order_id) as order_count, @@ -493,14 +494,14 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id WHERE pr.product_id = p.id - ), p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as standard_hpp_total, + ), p.cost, 0) * COALESCE(SUM(`+billableQty+`), 0) as standard_hpp_total, CASE - WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(` + billableQty + `) + WHEN SUM(`+billableQty+`) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(`+billableQty+`) ELSE 0 END as fifo_hpp_per_unit, COALESCE(SUM(oi.total_cost), 0) as fifo_hpp_total, COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit, - COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as moving_average_hpp_total + COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(`+billableQty+`), 0) as moving_average_hpp_total `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). @@ -551,9 +552,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_moving_average_hpp `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). @@ -604,9 +605,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte COUNT(DISTINCT c.id) as category_count, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_moving_average_hpp `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 8590c18..a6267cc 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -60,6 +60,8 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe OrderCount: item.OrderCount, PaymentCount: item.PaymentCount, Percentage: item.Percentage, + PointsUsed: item.PointsUsed, + CountsAsCashIn: item.CountsAsCashIn, }) } @@ -72,6 +74,9 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe GroupBy: resp.GroupBy, Summary: contract.PaymentMethodSummary{ TotalAmount: resp.Summary.TotalAmount, + PointAmount: resp.Summary.PointAmount, + PointsUsed: resp.Summary.PointsUsed, + TotalWithPoints: resp.Summary.TotalWithPoints, TotalOrders: resp.Summary.TotalOrders, TotalPayments: resp.Summary.TotalPayments, AverageOrderValue: resp.Summary.AverageOrderValue, @@ -617,6 +622,8 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse) OrderCount: item.OrderCount, PaymentCount: item.PaymentCount, Percentage: item.Percentage, + PointsUsed: item.PointsUsed, + CountsAsCashIn: item.CountsAsCashIn, }) } -- 2.54.0 From ab3425070bff2e1241f76851c6f20fc4a2289969 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 12:09:53 +0700 Subject: [PATCH 34/47] feat(loyalty): exchange EnakCoin into EnakPoint Adds GET /customer/wallet/exchange/preview?coins= and POST /customer/wallet/exchange (docs/prd-point-coin.md F4, K3, PC-401). The customer exchanges a multiple of the organization's coin_amount and gets (coins / coin_amount) x point_amount EnakPoint, approved by their PIN (K8). A malformed amount is refused before the PIN is checked, so it costs no attempt. In one transaction the wallet is locked, EXCHANGE_OUT takes the EnakCoin in K9 order and EXCHANGE_IN adds the EnakPoint; the two rows share a group, point at each other and both freeze the rate in their metadata. The EnakPoint are split over the EnakCoin lots they came from, each part keeping its lot's expiry and pointing back at it, so exchanging cannot extend a balance's life. The split takes floor(coins so far x rate) per lot, which adds up exactly because the total is a multiple of coin_amount. EnakPoint have no validity of their own until the expiry model is decided (N4), so the EnakCoin lot is for now the only bound. The Idempotency-Key header (or X-Idempotency-Key) is required. A retry with the same key is recognised under the wallet lock and replayed with the ids and rate the first attempt froze, even if the rate has changed since; the same key for another amount is refused. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 7 + internal/contract/wallet_contract.go | 7 + internal/handler/customer_wallet_handler.go | 70 ++++ internal/models/wallet_move.go | 44 +++ .../processor/wallet_exchange_processor.go | 290 +++++++++++++++++ .../wallet_exchange_processor_test.go | 305 ++++++++++++++++++ internal/processor/wallet_move_db_test.go | 94 ++++++ internal/processor/wallet_processor.go | 7 + internal/repository/wallet_move_repository.go | 56 ++++ internal/router/router.go | 8 +- internal/router/router_test.go | 2 + internal/service/customer_wallet_service.go | 66 ++++ 12 files changed, 954 insertions(+), 2 deletions(-) create mode 100644 internal/handler/customer_wallet_handler.go create mode 100644 internal/models/wallet_move.go create mode 100644 internal/processor/wallet_exchange_processor.go create mode 100644 internal/processor/wallet_exchange_processor_test.go create mode 100644 internal/processor/wallet_move_db_test.go create mode 100644 internal/repository/wallet_move_repository.go create mode 100644 internal/service/customer_wallet_service.go diff --git a/internal/app/app.go b/internal/app/app.go index c207899..90e216d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -159,6 +159,7 @@ func (a *App) Initialize(cfg *config.Config) error { services.customerPinService, services.pointPaymentService, services.customerOrderPaymentService, + services.customerWalletService, a.redisClient, ) @@ -392,6 +393,7 @@ type processors struct { customerPinProcessor *processor.CustomerPinProcessor paymentCodeProcessor *processor.PaymentCodeProcessor pointPaymentProcessor *processor.PointPaymentProcessor + walletExchangeProcessor *processor.WalletExchangeProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -411,6 +413,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor // Pay orders with EnakPoint, approved by the customer's one-time code (docs/prd-point-coin.md F9) pointPaymentProcessor := processor.NewPointPaymentProcessor(repository.NewPointPaymentRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor) + // Exchange EnakCoin into EnakPoint, approved by the customer's PIN (docs/prd-point-coin.md F4) + walletExchangeProcessor := processor.NewWalletExchangeProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), @@ -463,6 +467,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor customerPinProcessor: customerPinProcessor, paymentCodeProcessor: paymentCodeProcessor, pointPaymentProcessor: pointPaymentProcessor, + walletExchangeProcessor: walletExchangeProcessor, walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), } } @@ -511,6 +516,7 @@ type services struct { customerPinService *service.CustomerPinServiceImpl pointPaymentService *service.PointPaymentServiceImpl customerOrderPaymentService *service.CustomerOrderPaymentServiceImpl + customerWalletService *service.CustomerWalletServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -597,6 +603,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor), pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor), customerOrderPaymentService: service.NewCustomerOrderPaymentService(processors.orderProcessor), + customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor), } } diff --git a/internal/contract/wallet_contract.go b/internal/contract/wallet_contract.go index 3e0e58a..9d7d133 100644 --- a/internal/contract/wallet_contract.go +++ b/internal/contract/wallet_contract.go @@ -12,3 +12,10 @@ type AdjustCustomerWalletRequest struct { // adjusting twice, so a retried request is safe. IdempotencyKey string `json:"idempotency_key"` } + +// ExchangeCoinsRequest is POST /customer/wallet/exchange (docs/prd-point-coin.md F4). +// The Idempotency-Key header is required. +type ExchangeCoinsRequest struct { + Coins int64 `json:"coins" binding:"required,min=1"` + Pin string `json:"pin" binding:"required"` +} diff --git a/internal/handler/customer_wallet_handler.go b/internal/handler/customer_wallet_handler.go new file mode 100644 index 0000000..2faffbb --- /dev/null +++ b/internal/handler/customer_wallet_handler.go @@ -0,0 +1,70 @@ +package handler + +import ( + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" +) + +// The header exchanges and transfers require (docs/prd-point-coin.md F4, F5), and the +// X- form the rest of the API uses, which is accepted too. +const ( + idempotencyKeyHeader = "Idempotency-Key" + legacyIdempotencyKeyHeader = "X-Idempotency-Key" +) + +// CustomerWalletHandler serves what the customer app does with its own wallet. Bodies +// hold the PIN, so they are never logged. +type CustomerWalletHandler struct { + wallets service.CustomerWalletService +} + +func NewCustomerWalletHandler(wallets service.CustomerWalletService) *CustomerWalletHandler { + return &CustomerWalletHandler{wallets: wallets} +} + +// PreviewExchange is GET /customer/wallet/exchange/preview?coins=. +func (h *CustomerWalletHandler) PreviewExchange(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::PreviewExchange") + if !ok { + return + } + var coins int64 + if raw := c.Query("coins"); raw != "" { + var err error + coins, err = strconv.ParseInt(raw, 10, 64) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "coins must be a whole number"), + }), "CustomerWalletHandler::PreviewExchange") + return + } + } + util.HandleResponse(c.Writer, c.Request, h.wallets.PreviewExchange(c.Request.Context(), customerID, coins), "CustomerWalletHandler::PreviewExchange") +} + +// Exchange is POST /customer/wallet/exchange. +func (h *CustomerWalletHandler) Exchange(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::Exchange") + if !ok { + return + } + var req contract.ExchangeCoinsRequest + if !bindPinRequest(c, &req, "CustomerWalletHandler::Exchange") { + return + } + util.HandleResponse(c.Writer, c.Request, h.wallets.Exchange(c.Request.Context(), customerID, &req, idempotencyKey(c), pinRequestInfo(c)), "CustomerWalletHandler::Exchange") +} + +func idempotencyKey(c *gin.Context) string { + if key := strings.TrimSpace(c.GetHeader(idempotencyKeyHeader)); key != "" { + return key + } + return strings.TrimSpace(c.GetHeader(legacyIdempotencyKeyHeader)) +} diff --git a/internal/models/wallet_move.go b/internal/models/wallet_move.go new file mode 100644 index 0000000..5eb0c64 --- /dev/null +++ b/internal/models/wallet_move.go @@ -0,0 +1,44 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// WalletExchangePreview is GET /customer/wallet/exchange/preview +// (docs/prd-point-coin.md F4): the rate, and what exchanging Coins would give. +type WalletExchangePreview struct { + // The rate: CoinAmount EnakCoin exchange into PointAmount EnakPoint. + CoinAmount int64 `json:"coin_amount"` + PointAmount int64 `json:"point_amount"` + CoinBalance int64 `json:"coin_balance"` + Coins int64 `json:"coins"` + Points int64 `json:"points"` + // Whether Coins can be exchanged now, and why not when it cannot. + Valid bool `json:"valid"` + Reason string `json:"reason,omitempty"` +} + +// WalletMovedLot is part of what an exchange or a transfer delivered, with the +// expiry it carried over from the lot it came from (K9). +type WalletMovedLot struct { + Amount int64 `json:"amount"` + // Nil when it never expires. + ExpiresAt *time.Time `json:"expires_at"` +} + +// WalletExchangeResult is POST /customer/wallet/exchange. +type WalletExchangeResult struct { + GroupID uuid.UUID `json:"group_id"` + Coins int64 `json:"coins"` + Points int64 `json:"points"` + CoinAmount int64 `json:"coin_amount"` + PointAmount int64 `json:"point_amount"` + // The EnakPoint received, split by expiry. + Lots []WalletMovedLot `json:"lots"` + CoinBalance int64 `json:"coin_balance"` + PointBalance int64 `json:"point_balance"` + // True when this was a retry of an exchange already made; nothing moved again. + Replayed bool `json:"replayed"` +} diff --git a/internal/processor/wallet_exchange_processor.go b/internal/processor/wallet_exchange_processor.go new file mode 100644 index 0000000..79beac3 --- /dev/null +++ b/internal/processor/wallet_exchange_processor.go @@ -0,0 +1,290 @@ +package processor + +import ( + "context" + "errors" + "fmt" + "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" +) + +// ErrWalletMoveRejected wraps every reason an exchange or a transfer is refused on +// the customer's side: the amount, the limits, the recipient or the balance. The +// message says which. +var ErrWalletMoveRejected = errors.New("wallet move refused") + +// walletMoveKeyLimit keeps a client's Idempotency-Key short enough to fit, with the +// prefix that scopes it to the customer, in wallet_transactions.idempotency_key. +const walletMoveKeyLimit = 50 + +type organizationSettingsReader interface { + Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) +} + +// WalletExchangeProcessor exchanges EnakCoin into EnakPoint (docs/prd-point-coin.md +// F4, K3). It is one way only; nothing turns EnakPoint back into EnakCoin. +type WalletExchangeProcessor struct { + customers repository.WalletMoveRepository + settings organizationSettingsReader + spendable spendableReader + pins pinVerifier + wallet *WalletProcessor + tx TxRunner + now func() time.Time +} + +func NewWalletExchangeProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner) *WalletExchangeProcessor { + return &WalletExchangeProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, now: time.Now} +} + +// Preview is GET /customer/wallet/exchange/preview: the organization's rate and what +// exchanging coins would give, so the app can show it before asking for the PIN. +func (p *WalletExchangeProcessor) Preview(ctx context.Context, customerID uuid.UUID, coins int64) (*models.WalletExchangePreview, error) { + customer, err := p.customers.GetCustomer(ctx, customerID) + if err != nil { + return nil, err + } + settings, err := p.settings.Organization(ctx, customer.OrganizationID) + if err != nil { + return nil, err + } + balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now()) + if err != nil { + return nil, err + } + rate := settings.Exchange + preview := &models.WalletExchangePreview{ + CoinAmount: rate.CoinAmount, + PointAmount: rate.PointAmount, + CoinBalance: balances[constants.WalletCurrencyCoin], + Coins: coins, + } + if reason := exchangeProblem(customer, rate, coins); reason != "" { + preview.Reason = reason + return preview, nil + } + preview.Points = exchangePoints(coins, rate) + if coins > preview.CoinBalance { + preview.Reason = "not enough EnakCoin" + return preview, nil + } + preview.Valid = true + return preview, nil +} + +// Exchange takes coins EnakCoin and gives the EnakPoint they are worth at the +// organization's rate, in one transaction. The two ledger rows share a group and +// point at each other, and both freeze the rate. Each EnakPoint lot keeps the expiry +// of the EnakCoin lot it came from, so exchanging cannot extend a balance's life +// (K9). The PIN approves it (K8). +// +// idempotencyKey is the client's Idempotency-Key: a retry with the same key returns +// the first exchange, at the rate it was made, without moving anything again. +func (p *WalletExchangeProcessor) Exchange(ctx context.Context, customerID uuid.UUID, coins int64, pin, idempotencyKey string, info models.CustomerPinRequestInfo) (*models.WalletExchangeResult, error) { + key, err := walletMoveKey(idempotencyKey) + if err != nil { + return nil, err + } + customer, err := p.customers.GetCustomer(ctx, customerID) + if err != nil { + return nil, err + } + settings, err := p.settings.Organization(ctx, customer.OrganizationID) + if err != nil { + return nil, err + } + // Refuse a malformed request before the PIN is checked, so a typo in the amount + // costs the customer no PIN attempt. + if reason := exchangeProblem(customer, settings.Exchange, coins); reason != "" { + return nil, fmt.Errorf("%w: %s", ErrWalletMoveRejected, reason) + } + if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionExchange, info); err != nil { + return nil, err + } + + outKey := fmt.Sprintf("exchange:%s:%s:out", customerID, key) + inKey := fmt.Sprintf("exchange:%s:%s:in", customerID, key) + result := &models.WalletExchangeResult{Coins: coins} + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + if err := p.wallet.LockWallet(ctx, customerID); err != nil { + return err + } + rate := settings.Exchange + groupID, outID, inID := uuid.New(), uuid.New(), uuid.New() + previous, err := p.wallet.FindTransaction(ctx, outKey) + if err != nil { + return err + } + if previous != nil && previous.GroupID != nil { + // A retry: repeat it with the ids and the rate the first attempt froze, so + // both rows replay even if the rate has changed since. + outID, inID, groupID = previous.ID, previous.ReferenceID, *previous.GroupID + rate = frozenExchangeRate(previous.Metadata, rate) + } + points := exchangePoints(coins, rate) + metadata := entities.Metadata{ + "coins": coins, + "points": points, + "coin_amount": rate.CoinAmount, + "point_amount": rate.PointAmount, + } + + out, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ + TransactionID: outID, + CustomerID: customerID, + Currency: constants.WalletCurrencyCoin, + Type: constants.WalletTxTypeExchangeOut, + Amount: coins, + ReferenceType: constants.WalletRefTypeWalletTx, + ReferenceID: inID, + GroupID: &groupID, + Description: fmt.Sprintf("Tukar %d EnakCoin ke EnakPoint", coins), + Metadata: metadata, + IdempotencyKey: outKey, + }}) + if errors.Is(err, repository.ErrWalletInsufficientBalance) { + return fmt.Errorf("%w: not enough EnakCoin", ErrWalletMoveRejected) + } + if err != nil { + return err + } + in, err := p.wallet.Credit(ctx, WalletCreditInput{ + WalletEntry: WalletEntry{ + TransactionID: inID, + CustomerID: customerID, + Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypeExchangeIn, + Amount: points, + ReferenceType: constants.WalletRefTypeWalletTx, + ReferenceID: outID, + GroupID: &groupID, + Description: fmt.Sprintf("Dari tukar %d EnakCoin", coins), + Metadata: metadata, + IdempotencyKey: inKey, + }, + Lots: exchangeLots(out.Allocations, rate), + }) + if err != nil { + return err + } + + result.GroupID = groupID + result.Points = points + result.CoinAmount = rate.CoinAmount + result.PointAmount = rate.PointAmount + result.Lots = movedLots(in.Lots) + result.Replayed = out.Replayed + return nil + }) + if err != nil { + return nil, err + } + + balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now()) + if err != nil { + return nil, err + } + result.CoinBalance = balances[constants.WalletCurrencyCoin] + result.PointBalance = balances[constants.WalletCurrencyPoint] + return result, nil +} + +// exchangeProblem says why coins cannot be exchanged, or "" when they can as far as +// the request goes. The balance is checked under the wallet lock. +func exchangeProblem(customer *repository.WalletMoveCustomer, rate models.LoyaltyExchangeSettings, coins int64) string { + switch { + case !customer.IsActive: + return "the customer is not active" + case coins <= 0: + return "the number of EnakCoin must be positive" + case rate.CoinAmount <= 0 || rate.PointAmount <= 0: + return "exchange is not available" + case coins%rate.CoinAmount != 0: + // Otherwise part of the EnakCoin would be lost to rounding (F4). + return fmt.Sprintf("EnakCoin are exchanged in multiples of %d", rate.CoinAmount) + } + return "" +} + +// exchangePoints is (coins / coin_amount) × point_amount, for coins that are a +// multiple of coin_amount. +func exchangePoints(coins int64, rate models.LoyaltyExchangeSettings) int64 { + return coins / rate.CoinAmount * rate.PointAmount +} + +// exchangeLots splits the EnakPoint of an exchange over the EnakCoin lots it took, +// so each part keeps the expiry of its lot and points back at it (K9). The share of +// a lot is the difference of floor(coins so far × point_amount / coin_amount) before +// and after it, which adds up exactly because the total is a multiple of +// coin_amount. A lot too small to earn a whole EnakPoint on its own gives none. +// +// F4 caps the expiry at now + the EnakPoint validity as well. EnakPoint have no +// validity until the expiry model is decided (F12, note N4), so for now the EnakCoin +// lot's expiry is the only bound. +func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings) []WalletLotInput { + var lots []WalletLotInput + var coinsSoFar int64 + for _, a := range allocations { + before := coinsSoFar * rate.PointAmount / rate.CoinAmount + coinsSoFar += a.Amount + points := coinsSoFar*rate.PointAmount/rate.CoinAmount - before + if points == 0 { + continue + } + lotID := a.LotID + lots = append(lots, WalletLotInput{Amount: points, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID}) + } + return lots +} + +// frozenExchangeRate reads the rate an exchange was made at from its ledger row. +func frozenExchangeRate(metadata entities.Metadata, fallback models.LoyaltyExchangeSettings) models.LoyaltyExchangeSettings { + coinAmount, ok1 := metadataInt(metadata, "coin_amount") + pointAmount, ok2 := metadataInt(metadata, "point_amount") + if !ok1 || !ok2 || coinAmount <= 0 || pointAmount <= 0 { + return fallback + } + return models.LoyaltyExchangeSettings{CoinAmount: coinAmount, PointAmount: pointAmount} +} + +// metadataInt reads a whole number from metadata that may have been through JSONB, +// which gives numbers back as float64. +func metadataInt(metadata entities.Metadata, key string) (int64, bool) { + switch v := metadata[key].(type) { + case float64: + return int64(v), true + case int64: + return v, true + case int: + return int64(v), true + } + return 0, false +} + +func movedLots(lots []entities.WalletLot) []models.WalletMovedLot { + out := make([]models.WalletMovedLot, 0, len(lots)) + for _, lot := range lots { + out = append(out, models.WalletMovedLot{Amount: lot.OriginalAmount, ExpiresAt: lot.ExpiresAt}) + } + return out +} + +// walletMoveKey checks the client's Idempotency-Key, which exchanges and transfers +// require (F4, F5). +func walletMoveKey(key string) (string, error) { + key = strings.TrimSpace(key) + if key == "" { + return "", fmt.Errorf("%w: the Idempotency-Key header is required", ErrWalletMoveRejected) + } + if len(key) > walletMoveKeyLimit { + return "", fmt.Errorf("%w: the Idempotency-Key header must be at most %d characters", ErrWalletMoveRejected, walletMoveKeyLimit) + } + return key, nil +} diff --git a/internal/processor/wallet_exchange_processor_test.go b/internal/processor/wallet_exchange_processor_test.go new file mode 100644 index 0000000..8306751 --- /dev/null +++ b/internal/processor/wallet_exchange_processor_test.go @@ -0,0 +1,305 @@ +package processor + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// walletMoveEnv runs exchanges and transfers on the in-memory wallet, so every test +// also checks the §7.5 invariants when it ends. +type walletMoveEnv struct { + *walletTestEnv + customers *walletMoveRepoFake + settings *models.OrganizationLoyaltySettings + pins *movePinFake +} + +func newWalletMoveEnv(t *testing.T) *walletMoveEnv { + e := &walletMoveEnv{ + walletTestEnv: newWalletTestEnv(t), + customers: &walletMoveRepoFake{byID: map[uuid.UUID]*repository.WalletMoveCustomer{}}, + settings: &models.OrganizationLoyaltySettings{ + PointValue: 1, + Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}, + Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1}, + }, + pins: &movePinFake{good: "482913"}, + } + return e +} + +// member adds an active customer of the organization. +func (e *walletMoveEnv) member(name, phone string) uuid.UUID { + id := e.customer() + e.customers.byID[id] = &repository.WalletMoveCustomer{ + ID: id, OrganizationID: e.org, Name: name, PhoneNumber: &phone, IsActive: true, + } + return id +} + +func (e *walletMoveEnv) exchanges() *WalletExchangeProcessor { + p := NewWalletExchangeProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}) + p.now = func() time.Time { return e.now } + return p +} + +func (e *walletMoveEnv) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) { + s := *e.settings + return &s, nil +} + +// SpendableBalances sums the unexpired lots, as the real query does. +func (e *walletMoveEnv) SpendableBalances(_ context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) { + out := map[string]int64{} + for _, lot := range e.repo.lots { + if lot.CustomerID == customerID && (lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf)) { + out[lot.Currency] += lot.RemainingAmount + } + } + return out, nil +} + +// earnCoins gives a customer an EnakCoin lot. +func (e *walletMoveEnv) earnCoins(t *testing.T, customerID uuid.UUID, amount int64, expiresAt *time.Time) *WalletResult { + t.Helper() + in := earn(customerID, amount, expiresAt) + in.Currency = constants.WalletCurrencyCoin + return e.credit(t, in) +} + +func (e *walletMoveEnv) coinBalance(t *testing.T, customerID uuid.UUID) int64 { + t.Helper() + w, err := e.repo.GetWallet(e.ctx, customerID) + require.NoError(t, err) + return w.CoinBalance +} + +type walletMoveRepoFake struct { + byID map[uuid.UUID]*repository.WalletMoveCustomer +} + +func (f *walletMoveRepoFake) GetCustomer(_ context.Context, id uuid.UUID) (*repository.WalletMoveCustomer, error) { + c, ok := f.byID[id] + if !ok { + return nil, repository.ErrWalletNotFound + } + copied := *c + return &copied, nil +} + +// movePinFake accepts one PIN and records the actions it was asked to approve. +type movePinFake struct { + good string + err error + actions []PinAction +} + +func (f *movePinFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error { + f.actions = append(f.actions, action) + if f.err != nil { + return f.err + } + if pin != f.good { + return &PinError{Code: PinErrInvalid, RemainingAttempts: 4} + } + return nil +} + +func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) { + e := newWalletMoveEnv(t) + c := e.member("Budi Santoso", "081234561234") + e.earnCoins(t, c, 50, nil) + + res, err := e.exchanges().Exchange(e.ctx, c, 50, "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + + assert.Equal(t, int64(50), res.Points) + assert.Equal(t, int64(0), res.CoinBalance) + assert.Equal(t, int64(50), res.PointBalance) + assert.Equal(t, []PinAction{PinActionExchange}, e.pins.actions) + + out, in := e.repo.transactions[1], e.repo.transactions[2] + assert.Equal(t, constants.WalletTxTypeExchangeOut, out.Type) + assert.Equal(t, constants.WalletCurrencyCoin, out.Currency) + assert.Equal(t, int64(-50), out.Amount) + assert.Equal(t, constants.WalletTxTypeExchangeIn, in.Type) + assert.Equal(t, constants.WalletCurrencyPoint, in.Currency) + assert.Equal(t, int64(50), in.Amount) + // The pair shares a group and each row points at the other (§8.1). + assert.Equal(t, *out.GroupID, *in.GroupID) + assert.Equal(t, res.GroupID, *out.GroupID) + assert.Equal(t, in.ID, out.ReferenceID) + assert.Equal(t, out.ID, in.ReferenceID) + assert.Equal(t, "Tukar 50 EnakCoin ke EnakPoint", out.Description) + assert.Equal(t, "Dari tukar 50 EnakCoin", in.Description) + // Both rows freeze the rate. + for _, row := range []*entities.WalletTransaction{out, in} { + assert.Equal(t, int64(1), row.Metadata["coin_amount"]) + assert.Equal(t, int64(1), row.Metadata["point_amount"]) + } +} + +func TestWalletExchange_TenCoinsForThreePoints(t *testing.T) { + e := newWalletMoveEnv(t) + e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} + c := e.member("Budi", "081234561234") + e.earnCoins(t, c, 35, nil) + + preview, err := e.exchanges().Preview(e.ctx, c, 30) + require.NoError(t, err) + assert.True(t, preview.Valid) + assert.Equal(t, int64(9), preview.Points) + assert.Equal(t, int64(35), preview.CoinBalance) + + res, err := e.exchanges().Exchange(e.ctx, c, 30, "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + assert.Equal(t, int64(9), res.Points) + assert.Equal(t, int64(5), res.CoinBalance) + assert.Equal(t, int64(9), res.PointBalance) +} + +func TestWalletExchange_RefusesAmountsThatAreNotAMultiple(t *testing.T) { + e := newWalletMoveEnv(t) + e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} + c := e.member("Budi", "081234561234") + e.earnCoins(t, c, 50, nil) + + preview, err := e.exchanges().Preview(e.ctx, c, 25) + require.NoError(t, err) + assert.False(t, preview.Valid) + assert.Contains(t, preview.Reason, "multiples of 10") + + _, err = e.exchanges().Exchange(e.ctx, c, 25, "482913", "key-1", models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, ErrWalletMoveRejected) + // Refused before the PIN, so a typo costs no attempt. + assert.Empty(t, e.pins.actions) + assert.Equal(t, int64(50), e.coinBalance(t, c)) + + _, err = e.exchanges().Exchange(e.ctx, c, 0, "482913", "key-2", models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, ErrWalletMoveRejected) +} + +func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) { + e := newWalletMoveEnv(t) + e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} + c := e.member("Budi", "081234561234") + soon, later := e.at(24*time.Hour), e.at(48*time.Hour) + first := e.earnCoins(t, c, 15, soon) + second := e.earnCoins(t, c, 15, later) + third := e.earnCoins(t, c, 10, nil) + + // 40 EnakCoin take 15 from the lot expiring soonest, 15 from the next and 10 from + // the one that never expires, giving 12 EnakPoint split 4 + 5 + 3. + res, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + require.Len(t, res.Lots, 3) + assert.Equal(t, models.WalletMovedLot{Amount: 4, ExpiresAt: soon}, res.Lots[0]) + assert.Equal(t, models.WalletMovedLot{Amount: 5, ExpiresAt: later}, res.Lots[1]) + assert.Equal(t, models.WalletMovedLot{Amount: 3}, res.Lots[2]) + + origins := map[uuid.UUID]*time.Time{} + for _, lot := range e.repo.lots { + if lot.Currency == constants.WalletCurrencyPoint { + require.NotNil(t, lot.OriginLotID) + origins[*lot.OriginLotID] = lot.ExpiresAt + } + } + assert.Equal(t, map[uuid.UUID]*time.Time{ + first.Lots[0].ID: soon, second.Lots[0].ID: later, third.Lots[0].ID: nil, + }, origins) + for _, lot := range e.repo.lots { + if lot.Currency != constants.WalletCurrencyPoint || lot.OriginLotID == nil { + continue + } + for _, coinLot := range e.repo.lots { + if coinLot.ID == *lot.OriginLotID && coinLot.ExpiresAt != nil { + require.NotNil(t, lot.ExpiresAt, "a lot that expires cannot become one that does not") + assert.False(t, lot.ExpiresAt.After(*coinLot.ExpiresAt)) + } + } + } +} + +func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) { + e := newWalletMoveEnv(t) + e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1} + c := e.member("Budi", "081234561234") + e.earnCoins(t, c, 5, e.at(time.Hour)) + e.earnCoins(t, c, 5, nil) + + // Neither lot is worth a whole EnakPoint alone; the one that completes the 10 + // carries it. + res, err := e.exchanges().Exchange(e.ctx, c, 10, "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + assert.Equal(t, []models.WalletMovedLot{{Amount: 1}}, res.Lots) +} + +func TestWalletExchange_NotEnoughCoins(t *testing.T) { + e := newWalletMoveEnv(t) + c := e.member("Budi", "081234561234") + e.earnCoins(t, c, 5, nil) + + preview, err := e.exchanges().Preview(e.ctx, c, 6) + require.NoError(t, err) + assert.False(t, preview.Valid) + + _, err = e.exchanges().Exchange(e.ctx, c, 6, "482913", "key-1", models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, ErrWalletMoveRejected) + assert.Equal(t, int64(5), e.coinBalance(t, c)) +} + +func TestWalletExchange_WrongPinMovesNothing(t *testing.T) { + e := newWalletMoveEnv(t) + c := e.member("Budi", "081234561234") + e.earnCoins(t, c, 5, nil) + + _, err := e.exchanges().Exchange(e.ctx, c, 5, "000000", "key-1", models.CustomerPinRequestInfo{}) + var pinErr *PinError + require.True(t, errors.As(err, &pinErr)) + assert.Equal(t, PinErrInvalid, pinErr.Code) + assert.Equal(t, int64(5), e.coinBalance(t, c)) +} + +func TestWalletExchange_RetryReturnsTheFirstExchangeAtItsRate(t *testing.T) { + e := newWalletMoveEnv(t) + c := e.member("Budi", "081234561234") + e.earnCoins(t, c, 100, nil) + + first, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + // The rate changes before the retry arrives; the retry still gets the first result. + e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 2} + again, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + + assert.True(t, again.Replayed) + assert.Equal(t, first.GroupID, again.GroupID) + assert.Equal(t, int64(40), again.Points) + assert.Equal(t, int64(60), e.coinBalance(t, c)) + assert.Len(t, e.repo.transactions, 3) + + // The same key for a different amount is not a retry. + _, err = e.exchanges().Exchange(e.ctx, c, 20, "482913", "key-1", models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) +} + +func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) { + e := newWalletMoveEnv(t) + c := e.member("Budi", "081234561234") + e.earnCoins(t, c, 5, nil) + + _, err := e.exchanges().Exchange(e.ctx, c, 5, "482913", " ", models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, ErrWalletMoveRejected) + assert.Empty(t, e.pins.actions) +} diff --git a/internal/processor/wallet_move_db_test.go b/internal/processor/wallet_move_db_test.go new file mode 100644 index 0000000..e65c062 --- /dev/null +++ b/internal/processor/wallet_move_db_test.go @@ -0,0 +1,94 @@ +package processor + +import ( + "context" + "os" + "testing" + "time" + + "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/models" + "apskel-pos-be/internal/repository" +) + +// fixedOrganizationSettings serves the same organization settings to every caller. +type fixedOrganizationSettings struct{ s models.OrganizationLoyaltySettings } + +func (f fixedOrganizationSettings) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) { + s := f.s + return &s, nil +} + +// walletMoveDB opens TEST_DATABASE_URL and creates an organization with two customers, +// removed again when the test ends. See internal/repository/wallet_repository_test.go. +func walletMoveDB(t *testing.T) (db *gorm.DB, org, a, b uuid.UUID) { + t.Helper() + 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) + + org, a, b = uuid.New(), uuid.New(), uuid.New() + phoneA, phoneB := "08"+a.String()[:10], "08"+b.String()[:10] + require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet move test', 'basic')`, org).Error) + require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Anita', ?), (?, ?, 'Budi Santoso', ?)`, + a, org, phoneA, b, org, phoneB).Error) + customers := []uuid.UUID{a, b} + 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 ? AND origin_lot_id IS NOT NULL`, 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) + }) + return db, org, a, b +} + +func TestWalletExchange_AgainstPostgres(t *testing.T) { + db, _, a, _ := walletMoveDB(t) + wallet := NewWalletProcessor(repository.NewWalletRepository(db)) + txm := repository.NewTxManager(db) + settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{ + Exchange: models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}, + }} + p := NewWalletExchangeProcessor(repository.NewWalletMoveRepository(db), settings, repository.NewWalletQueryRepository(db), + &movePinFake{good: "482913"}, wallet, txm) + + expiry := time.Now().Add(24 * time.Hour).Truncate(time.Second) + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + in := earn(a, 30, &expiry) + in.Currency = constants.WalletCurrencyCoin + _, err := wallet.Credit(ctx, in) + return err + })) + + res, err := p.Exchange(context.Background(), a, 20, "482913", "db-key", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + assert.Equal(t, int64(6), res.Points) + assert.Equal(t, int64(10), res.CoinBalance) + assert.Equal(t, int64(6), res.PointBalance) + require.Len(t, res.Lots, 1) + require.NotNil(t, res.Lots[0].ExpiresAt) + assert.True(t, res.Lots[0].ExpiresAt.Equal(expiry)) + + // The retry reads the frozen rate back out of JSONB and replays. + again, err := p.Exchange(context.Background(), a, 20, "482913", "db-key", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + assert.True(t, again.Replayed) + assert.Equal(t, int64(6), again.Points) + + var rows int64 + require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE group_id = ?`, res.GroupID).Scan(&rows).Error) + assert.Equal(t, int64(2), rows) +} diff --git a/internal/processor/wallet_processor.go b/internal/processor/wallet_processor.go index 3190fa3..28a22a2 100644 --- a/internal/processor/wallet_processor.go +++ b/internal/processor/wallet_processor.go @@ -142,6 +142,13 @@ func (p *WalletProcessor) LockWallets(ctx context.Context, a, b uuid.UUID) error return err } +// FindTransaction returns the row written under an idempotency key, or nil when the +// key is new. Read it under the wallet lock: a flow that writes several rows uses it +// to recognise a retry before checking limits the first attempt already counts in. +func (p *WalletProcessor) FindTransaction(ctx context.Context, idempotencyKey string) (*entities.WalletTransaction, error) { + return p.repo.GetTransactionByIdempotencyKey(ctx, idempotencyKey) +} + // Credit adds Amount to the wallet and creates its lots. func (p *WalletProcessor) Credit(ctx context.Context, in WalletCreditInput) (*WalletResult, error) { if err := validateWalletEntry(&in.WalletEntry, true); err != nil { diff --git a/internal/repository/wallet_move_repository.go b/internal/repository/wallet_move_repository.go new file mode 100644 index 0000000..08f983d --- /dev/null +++ b/internal/repository/wallet_move_repository.go @@ -0,0 +1,56 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// WalletMoveCustomer is a customer on either side of an exchange or a transfer. +type WalletMoveCustomer struct { + ID uuid.UUID + OrganizationID uuid.UUID + Name string + PhoneNumber *string + IsDefault bool + IsActive bool +} + +// WalletMoveRepository reads what exchanges and transfers check before moving a +// balance (docs/prd-point-coin.md F4, F5). The balance itself only moves through +// WalletProcessor. +type WalletMoveRepository interface { + // GetCustomer returns ErrWalletNotFound when the customer does not exist. + GetCustomer(ctx context.Context, customerID uuid.UUID) (*WalletMoveCustomer, error) +} + +type walletMoveRepository struct { + db *gorm.DB +} + +func NewWalletMoveRepository(db *gorm.DB) WalletMoveRepository { + return &walletMoveRepository{db: db} +} + +func (r *walletMoveRepository) GetCustomer(ctx context.Context, customerID uuid.UUID) (*WalletMoveCustomer, error) { + return r.customer(ctx, "id = ?", customerID) +} + +func (r *walletMoveRepository) customer(ctx context.Context, where string, arg interface{}) (*WalletMoveCustomer, error) { + var c WalletMoveCustomer + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table("customers"). + Select("id, organization_id, name, phone_number, is_default, is_active"). + Where(where, arg). + Take(&c).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrWalletNotFound + } + return nil, fmt.Errorf("failed to get customer: %w", err) + } + return &c, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index ffd18f5..7052040 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -59,12 +59,13 @@ type Router struct { customerPinHandler *handler.CustomerPinHandler pointPaymentHandler *handler.PointPaymentHandler customerOrderPaymentHandler *handler.CustomerOrderPaymentHandler - authMiddleware *middleware.AuthMiddleware + customerWalletHandler *handler.CustomerWalletHandler + authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, customerWalletService service.CustomerWalletService, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -115,6 +116,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer customerPinHandler: handler.NewCustomerPinHandler(customerPinService), pointPaymentHandler: handler.NewPointPaymentHandler(pointPaymentService), customerOrderPaymentHandler: handler.NewCustomerOrderPaymentHandler(customerOrderPaymentService), + customerWalletHandler: handler.NewCustomerWalletHandler(customerWalletService), redisClient: redisClient, } } @@ -169,6 +171,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions) customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode) + customer.GET("/wallet/exchange/preview", r.customerWalletHandler.PreviewExchange) + customer.POST("/wallet/exchange", r.customerWalletHandler.Exchange) customer.POST("/orders/:id/pay-with-points", r.customerOrderPaymentHandler.PayWithPoints) // PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11) customer.GET("/pin/status", r.customerPinHandler.Status) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 1baa348..a0b5daa 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -36,6 +36,8 @@ func TestAllRoutesRegister(t *testing.T) { "PUT /api/v1/marketing/loyalty-settings", "GET /api/v1/marketing/loyalty-settings/history", "POST /api/v1/customer/wallet/payment-code", + "GET /api/v1/customer/wallet/exchange/preview", + "POST /api/v1/customer/wallet/exchange", "GET /api/v1/orders/:id/point-payment/preview", "POST /api/v1/customer/orders/:id/pay-with-points", "GET /api/v1/customer/pin/status", diff --git a/internal/service/customer_wallet_service.go b/internal/service/customer_wallet_service.go new file mode 100644 index 0000000..fed1450 --- /dev/null +++ b/internal/service/customer_wallet_service.go @@ -0,0 +1,66 @@ +package service + +import ( + "context" + "errors" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" +) + +// CustomerWalletService moves balance on the customer's own request: exchanging +// EnakCoin into EnakPoint (docs/prd-point-coin.md F4). +type CustomerWalletService interface { + PreviewExchange(ctx context.Context, customerID uuid.UUID, coins int64) *contract.Response + Exchange(ctx context.Context, customerID uuid.UUID, req *contract.ExchangeCoinsRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response +} + +type CustomerWalletServiceImpl struct { + exchanges *processor.WalletExchangeProcessor +} + +func NewCustomerWalletService(exchanges *processor.WalletExchangeProcessor) *CustomerWalletServiceImpl { + return &CustomerWalletServiceImpl{exchanges: exchanges} +} + +func (s *CustomerWalletServiceImpl) PreviewExchange(ctx context.Context, customerID uuid.UUID, coins int64) *contract.Response { + preview, err := s.exchanges.Preview(ctx, customerID, coins) + if err != nil { + return walletMoveErrorResponse(err) + } + return contract.BuildSuccessResponse(preview) +} + +func (s *CustomerWalletServiceImpl) Exchange(ctx context.Context, customerID uuid.UUID, req *contract.ExchangeCoinsRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response { + result, err := s.exchanges.Exchange(ctx, customerID, req.Coins, req.Pin, idempotencyKey, info) + if err != nil { + return walletMoveErrorResponse(err) + } + return contract.BuildSuccessResponse(result) +} + +// walletMoveErrorResponse keeps the PIN codes the apps act on, and tells a refused +// request apart from a server failure. +func walletMoveErrorResponse(err error) *contract.Response { + var pinErr *processor.PinError + if errors.As(err, &pinErr) { + return PinErrorResponse(err) + } + code := constants.InternalServerErrorCode + switch { + case errors.Is(err, repository.ErrWalletNotFound): + code = constants.NotFoundErrorCode + case errors.Is(err, processor.ErrWalletMoveRejected), + errors.Is(err, processor.ErrWalletIdempotencyConflict), + errors.Is(err, processor.ErrWalletInvalidEntry): + code = constants.ValidationErrorCode + } + return contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(code, constants.WalletServiceEntity, err.Error()), + }) +} -- 2.54.0 From 694d65b6d89b9373ffbb621d7f9b454e59bff451 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 12:14:32 +0700 Subject: [PATCH 35/47] feat(loyalty): send EnakPoint and EnakCoin to another customer Adds GET /customer/wallet/transfer/recipient?phone= and POST /customer/wallet/transfer (docs/prd-point-coin.md F5, Q4, Q16, PC-402). The recipient is found by phone number and must be an active customer of the same organization, not the walk-in customer and not the sender. A number of another organization answers 404 like an unknown one, so the check does not reveal who uses the app elsewhere. The recipient check returns the name and number masked ("Bu*** Sa***", "08**-****-1234"). The organization's transfer settings apply: transfers turned off, the minimum, the maximum per transaction and the daily limit per currency, which starts over at midnight WIB. Everything the request alone can get wrong is refused before the PIN, so it costs no attempt; the PIN then refuses a transfer held for 24 hours after a PIN reset. Both wallets are locked in customer_id order, so transfers in opposite directions cannot deadlock, and the daily limit is summed under the lock. TRANSFER_OUT takes from the sender's lots in K9 order and TRANSFER_IN gives the recipient lots with exactly the same expiries, pointing back at the sender's lots. The rows share a group, reference each other and name the other customer; descriptions carry only the masked name. The Idempotency-Key header is required. A retry is recognised under the lock before the daily limit, so it replays instead of counting twice; the same key towards another recipient is refused. The recipient is told by WhatsApp after the commit, as PIN locks are: NotificationService only reaches staff devices, there is no push channel to customers yet. A failure to send is logged, never undoes the transfer. Transfers must not be released before note N3 (legal) is closed. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 6 +- internal/contract/wallet_contract.go | 10 + internal/handler/customer_wallet_handler.go | 22 ++ internal/models/wallet_move.go | 29 ++ .../wallet_exchange_processor_test.go | 31 +- internal/processor/wallet_move_db_test.go | 65 +++- .../processor/wallet_transfer_processor.go | 292 ++++++++++++++++++ .../wallet_transfer_processor_test.go | 225 ++++++++++++++ internal/repository/wallet_move_repository.go | 26 ++ internal/router/router.go | 2 + internal/router/router_test.go | 2 + internal/service/customer_wallet_service.go | 33 +- 12 files changed, 736 insertions(+), 7 deletions(-) create mode 100644 internal/processor/wallet_transfer_processor.go create mode 100644 internal/processor/wallet_transfer_processor_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 90e216d..68615a5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -394,6 +394,7 @@ type processors struct { paymentCodeProcessor *processor.PaymentCodeProcessor pointPaymentProcessor *processor.PointPaymentProcessor walletExchangeProcessor *processor.WalletExchangeProcessor + walletTransferProcessor *processor.WalletTransferProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -415,6 +416,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor) // Exchange EnakCoin into EnakPoint, approved by the customer's PIN (docs/prd-point-coin.md F4) walletExchangeProcessor := processor.NewWalletExchangeProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) + // Send EnakPoint or EnakCoin to another customer; the recipient is told by WhatsApp (docs/prd-point-coin.md F5) + walletTransferProcessor := processor.NewWalletTransferProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, otpProcessor) return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), @@ -468,6 +471,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor paymentCodeProcessor: paymentCodeProcessor, pointPaymentProcessor: pointPaymentProcessor, walletExchangeProcessor: walletExchangeProcessor, + walletTransferProcessor: walletTransferProcessor, walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), } } @@ -603,7 +607,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor), pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor), customerOrderPaymentService: service.NewCustomerOrderPaymentService(processors.orderProcessor), - customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor), + customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor, processors.walletTransferProcessor), } } diff --git a/internal/contract/wallet_contract.go b/internal/contract/wallet_contract.go index 9d7d133..d261a95 100644 --- a/internal/contract/wallet_contract.go +++ b/internal/contract/wallet_contract.go @@ -19,3 +19,13 @@ type ExchangeCoinsRequest struct { Coins int64 `json:"coins" binding:"required,min=1"` Pin string `json:"pin" binding:"required"` } + +// TransferWalletRequest is POST /customer/wallet/transfer (docs/prd-point-coin.md F5). +// The Idempotency-Key header is required. +type TransferWalletRequest struct { + // POINT or COIN. + Currency string `json:"currency" binding:"required"` + Amount int64 `json:"amount" binding:"required,min=1"` + RecipientPhone string `json:"recipient_phone" binding:"required"` + Pin string `json:"pin" binding:"required"` +} diff --git a/internal/handler/customer_wallet_handler.go b/internal/handler/customer_wallet_handler.go index 2faffbb..90aed18 100644 --- a/internal/handler/customer_wallet_handler.go +++ b/internal/handler/customer_wallet_handler.go @@ -68,3 +68,25 @@ func idempotencyKey(c *gin.Context) string { } return strings.TrimSpace(c.GetHeader(legacyIdempotencyKeyHeader)) } + +// TransferRecipient is GET /customer/wallet/transfer/recipient?phone=. +func (h *CustomerWalletHandler) TransferRecipient(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::TransferRecipient") + if !ok { + return + } + util.HandleResponse(c.Writer, c.Request, h.wallets.TransferRecipient(c.Request.Context(), customerID, c.Query("phone")), "CustomerWalletHandler::TransferRecipient") +} + +// Transfer is POST /customer/wallet/transfer. +func (h *CustomerWalletHandler) Transfer(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::Transfer") + if !ok { + return + } + var req contract.TransferWalletRequest + if !bindPinRequest(c, &req, "CustomerWalletHandler::Transfer") { + return + } + util.HandleResponse(c.Writer, c.Request, h.wallets.Transfer(c.Request.Context(), customerID, &req, idempotencyKey(c), pinRequestInfo(c)), "CustomerWalletHandler::Transfer") +} diff --git a/internal/models/wallet_move.go b/internal/models/wallet_move.go index 5eb0c64..d348cf4 100644 --- a/internal/models/wallet_move.go +++ b/internal/models/wallet_move.go @@ -42,3 +42,32 @@ type WalletExchangeResult struct { // True when this was a retry of an exchange already made; nothing moved again. Replayed bool `json:"replayed"` } + +// WalletTransferRecipient is GET /customer/wallet/transfer/recipient: who a phone +// number belongs to, masked, so the sender can check before confirming (F5). +type WalletTransferRecipient struct { + Name string `json:"name"` + PhoneNumber string `json:"phone_number"` +} + +// WalletTransfer is what a customer asks to send (F5). +type WalletTransfer struct { + // POINT or COIN. + Currency string + Amount int64 + RecipientPhone string +} + +// WalletTransferResult is POST /customer/wallet/transfer. +type WalletTransferResult struct { + GroupID uuid.UUID `json:"group_id"` + Currency string `json:"currency"` + Amount int64 `json:"amount"` + Recipient WalletTransferRecipient `json:"recipient"` + // What the recipient received, split by the expiry it carried over. + Lots []WalletMovedLot `json:"lots"` + // The sender's balance in the currency sent. + Balance int64 `json:"balance"` + // True when this was a retry of a transfer already made; nothing moved again. + Replayed bool `json:"replayed"` +} diff --git a/internal/processor/wallet_exchange_processor_test.go b/internal/processor/wallet_exchange_processor_test.go index 8306751..873dbd1 100644 --- a/internal/processor/wallet_exchange_processor_test.go +++ b/internal/processor/wallet_exchange_processor_test.go @@ -36,6 +36,9 @@ func newWalletMoveEnv(t *testing.T) *walletMoveEnv { }, pins: &movePinFake{good: "482913"}, } + e.customers.ledger = e.repo + // Ledger rows are stamped from now on, so "today" is the day of e.now. + e.repo.clock = e.now return e } @@ -54,6 +57,12 @@ func (e *walletMoveEnv) exchanges() *WalletExchangeProcessor { return p } +func (e *walletMoveEnv) transfers(messenger walletMessenger) *WalletTransferProcessor { + p := NewWalletTransferProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}, messenger) + p.now = func() time.Time { return e.now } + return p +} + func (e *walletMoveEnv) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) { s := *e.settings return &s, nil @@ -86,7 +95,8 @@ func (e *walletMoveEnv) coinBalance(t *testing.T, customerID uuid.UUID) int64 { } type walletMoveRepoFake struct { - byID map[uuid.UUID]*repository.WalletMoveCustomer + byID map[uuid.UUID]*repository.WalletMoveCustomer + ledger *walletRepoFake } func (f *walletMoveRepoFake) GetCustomer(_ context.Context, id uuid.UUID) (*repository.WalletMoveCustomer, error) { @@ -98,6 +108,25 @@ func (f *walletMoveRepoFake) GetCustomer(_ context.Context, id uuid.UUID) (*repo return &copied, nil } +func (f *walletMoveRepoFake) FindCustomerByPhone(ctx context.Context, phone string) (*repository.WalletMoveCustomer, error) { + for id, c := range f.byID { + if c.PhoneNumber != nil && *c.PhoneNumber == phone { + return f.GetCustomer(ctx, id) + } + } + return nil, repository.ErrWalletNotFound +} + +func (f *walletMoveRepoFake) TransferredOutSince(_ context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) { + var total int64 + for _, tx := range f.ledger.transactions { + if tx.CustomerID == customerID && tx.Currency == currency && tx.Type == constants.WalletTxTypeTransferOut && !tx.CreatedAt.Before(since) { + total -= tx.Amount + } + } + return total, nil +} + // movePinFake accepts one PIN and records the actions it was asked to approve. type movePinFake struct { good string diff --git a/internal/processor/wallet_move_db_test.go b/internal/processor/wallet_move_db_test.go index e65c062..a7d3690 100644 --- a/internal/processor/wallet_move_db_test.go +++ b/internal/processor/wallet_move_db_test.go @@ -2,7 +2,9 @@ package processor import ( "context" + "fmt" "os" + "sync" "testing" "time" @@ -19,7 +21,9 @@ import ( ) // fixedOrganizationSettings serves the same organization settings to every caller. -type fixedOrganizationSettings struct{ s models.OrganizationLoyaltySettings } +type fixedOrganizationSettings struct { + s models.OrganizationLoyaltySettings +} func (f fixedOrganizationSettings) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) { s := f.s @@ -92,3 +96,62 @@ func TestWalletExchange_AgainstPostgres(t *testing.T) { require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE group_id = ?`, res.GroupID).Scan(&rows).Error) assert.Equal(t, int64(2), rows) } + +// Transfers in both directions at once must not deadlock: both lock the two wallets +// in customer_id order. Every one of them lands, and the totals still reconcile. +func TestWalletTransfer_BothWaysAtOnceAgainstPostgres(t *testing.T) { + db, _, a, b := walletMoveDB(t) + wallet := NewWalletProcessor(repository.NewWalletRepository(db)) + txm := repository.NewTxManager(db) + moves := repository.NewWalletMoveRepository(db) + settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{ + Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1}, + }} + p := NewWalletTransferProcessor(moves, settings, repository.NewWalletQueryRepository(db), &movePinFake{good: "482913"}, wallet, txm, nil) + + expiry := time.Now().Add(24 * time.Hour).Truncate(time.Second) + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + if _, err := wallet.Credit(ctx, earn(a, 100, &expiry)); err != nil { + return err + } + _, err := wallet.Credit(ctx, earn(b, 100, nil)) + return err + })) + phone := func(id uuid.UUID) string { return "08" + id.String()[:10] } + + const rounds = 10 + errs := make(chan error, 2*rounds) + var wg sync.WaitGroup + for i := 0; i < rounds; i++ { + for _, pair := range [][2]uuid.UUID{{a, b}, {b, a}} { + wg.Add(1) + go func(from, to uuid.UUID, i int) { + defer wg.Done() + _, err := p.Transfer(context.Background(), from, sendPoints(1, phone(to)), "482913", fmt.Sprintf("race-%d", i), models.CustomerPinRequestInfo{}) + errs <- err + }(pair[0], pair[1], i) + } + } + wg.Wait() + close(errs) + for err := range errs { + assert.NoError(t, err) + } + + var balances []int64 + require.NoError(t, db.Raw(`SELECT point_balance FROM customer_wallets WHERE customer_id IN ? ORDER BY point_balance`, []uuid.UUID{a, b}).Scan(&balances).Error) + assert.Equal(t, []int64{100, 100}, balances) + + // B's lots that came from A keep A's expiry to the second. + var mismatched int64 + require.NoError(t, db.Raw(` + SELECT COUNT(*) FROM wallet_lots l JOIN wallet_lots o ON o.id = l.origin_lot_id + WHERE l.customer_id = ? AND o.customer_id = ? AND l.expires_at IS DISTINCT FROM o.expires_at`, b, a).Scan(&mismatched).Error) + assert.Zero(t, mismatched) + + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + sent, err := moves.TransferredOutSince(ctx, a, constants.WalletCurrencyPoint, startOfWalletDay(time.Now())) + assert.Equal(t, int64(rounds), sent) + return err + })) +} diff --git a/internal/processor/wallet_transfer_processor.go b/internal/processor/wallet_transfer_processor.go new file mode 100644 index 0000000..b13c846 --- /dev/null +++ b/internal/processor/wallet_transfer_processor.go @@ -0,0 +1,292 @@ +package processor + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// ErrWalletRecipientNotFound means no customer of the sender's organization has the +// phone number. A customer of another organization is reported the same way, so the +// check does not reveal who uses the app elsewhere. +var ErrWalletRecipientNotFound = errors.New("no customer of this organization has that phone number") + +// walletMessenger tells a customer something happened to their wallet. There is no +// push channel to customers yet, so the app sends it by WhatsApp. +type walletMessenger interface { + SendWhatsAppMessage(phoneNumber, message string) error +} + +// WalletTransferProcessor sends EnakPoint or EnakCoin from one customer to another in +// the same organization (docs/prd-point-coin.md F5). +type WalletTransferProcessor struct { + customers repository.WalletMoveRepository + settings organizationSettingsReader + spendable spendableReader + pins pinVerifier + wallet *WalletProcessor + tx TxRunner + messenger walletMessenger + now func() time.Time +} + +func NewWalletTransferProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner, messenger walletMessenger) *WalletTransferProcessor { + return &WalletTransferProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, messenger: messenger, now: time.Now} +} + +// Recipient is GET /customer/wallet/transfer/recipient: the masked name and number +// of the customer a phone number belongs to, if the sender may send to them. +func (p *WalletTransferProcessor) Recipient(ctx context.Context, senderID uuid.UUID, phoneNumber string) (*models.WalletTransferRecipient, error) { + sender, err := p.customers.GetCustomer(ctx, senderID) + if err != nil { + return nil, err + } + recipient, err := p.recipient(ctx, sender, phoneNumber) + if err != nil { + return nil, err + } + return maskedRecipient(recipient), nil +} + +// Transfer sends in.Amount of in.Currency to the customer with in.RecipientPhone, +// approved by the sender's PIN (K8), and tells the recipient. +// +// Both wallets are locked in customer_id order, so two transfers in opposite +// directions cannot deadlock. TRANSFER_OUT takes from the sender's lots in K9 order, +// and TRANSFER_IN gives the recipient lots with exactly the same expiries, pointing +// back at the sender's lots, so sending a balance back and forth cannot extend it. +// The two rows share a group and name each other's customer. +// +// idempotencyKey is the client's Idempotency-Key: a retry with the same key returns +// the first transfer without moving anything again or counting against the limits. +func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UUID, in models.WalletTransfer, pin, idempotencyKey string, info models.CustomerPinRequestInfo) (*models.WalletTransferResult, error) { + reject := func(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrWalletMoveRejected, fmt.Sprintf(format, args...)) + } + key, err := walletMoveKey(idempotencyKey) + if err != nil { + return nil, err + } + currency := strings.ToUpper(strings.TrimSpace(in.Currency)) + if !constants.IsValidWalletCurrency(currency) { + return nil, reject("currency must be POINT or COIN") + } + if in.Amount <= 0 { + return nil, reject("the amount must be positive") + } + sender, err := p.customers.GetCustomer(ctx, senderID) + if err != nil { + return nil, err + } + if !sender.IsActive { + return nil, reject("the customer is not active") + } + settings, err := p.settings.Organization(ctx, sender.OrganizationID) + if err != nil { + return nil, err + } + limits := settings.Transfer + switch { + case !limits.Enabled: + return nil, reject("transfers are turned off") + case in.Amount < limits.MinAmount: + return nil, reject("at least %d can be sent at a time", limits.MinAmount) + case limits.MaxPerTransaction != nil && in.Amount > *limits.MaxPerTransaction: + return nil, reject("at most %d can be sent at a time", *limits.MaxPerTransaction) + } + recipient, err := p.recipient(ctx, sender, in.RecipientPhone) + if err != nil { + return nil, err + } + // Everything the request alone can get wrong is refused above, before the PIN, so + // it costs no attempt. The PIN also refuses a transfer held after a PIN reset. + if err := p.pins.VerifyPin(ctx, senderID, pin, PinActionTransfer, info); err != nil { + return nil, err + } + + to, from := maskedRecipient(recipient), maskedRecipient(sender) + outKey := fmt.Sprintf("transfer:%s:%s:out", senderID, key) + inKey := fmt.Sprintf("transfer:%s:%s:in", senderID, key) + result := &models.WalletTransferResult{Currency: currency, Amount: in.Amount, Recipient: *to} + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + if err := p.wallet.LockWallets(ctx, senderID, recipient.ID); err != nil { + return err + } + groupID, outID, inID := uuid.New(), uuid.New(), uuid.New() + previous, err := p.wallet.FindTransaction(ctx, outKey) + if err != nil { + return err + } + if previous != nil { + // A retry: it replays below, so it must not count against the daily limit + // it is already part of. + if previous.CounterpartyCustomerID == nil || *previous.CounterpartyCustomerID != recipient.ID || previous.GroupID == nil { + return ErrWalletIdempotencyConflict + } + outID, inID, groupID = previous.ID, previous.ReferenceID, *previous.GroupID + } else if limits.DailyLimit != nil { + sent, err := p.customers.TransferredOutSince(ctx, senderID, currency, startOfWalletDay(p.now())) + if err != nil { + return err + } + if sent+in.Amount > *limits.DailyLimit { + return reject("at most %d can be sent per day; %d is left today", *limits.DailyLimit, max(*limits.DailyLimit-sent, 0)) + } + } + + out, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ + TransactionID: outID, + CustomerID: senderID, + Currency: currency, + Type: constants.WalletTxTypeTransferOut, + Amount: in.Amount, + ReferenceType: constants.WalletRefTypeWalletTx, + ReferenceID: inID, + GroupID: &groupID, + CounterpartyCustomerID: &recipient.ID, + Description: truncateRunes(fmt.Sprintf("Transfer ke %s (%s)", to.Name, to.PhoneNumber), walletDescriptionLimit), + IdempotencyKey: outKey, + }}) + if errors.Is(err, repository.ErrWalletInsufficientBalance) { + return reject("not enough %s", walletCurrencyName(currency)) + } + if err != nil { + return err + } + received, err := p.wallet.Credit(ctx, WalletCreditInput{ + WalletEntry: WalletEntry{ + TransactionID: inID, + CustomerID: recipient.ID, + Currency: currency, + Type: constants.WalletTxTypeTransferIn, + Amount: in.Amount, + ReferenceType: constants.WalletRefTypeWalletTx, + ReferenceID: outID, + GroupID: &groupID, + CounterpartyCustomerID: &senderID, + Description: truncateRunes(fmt.Sprintf("Transfer dari %s (%s)", from.Name, from.PhoneNumber), walletDescriptionLimit), + IdempotencyKey: inKey, + }, + Lots: out.CarryOver(), + }) + if err != nil { + return err + } + result.GroupID = groupID + result.Lots = movedLots(received.Lots) + result.Replayed = out.Replayed + return nil + }) + if err != nil { + return nil, err + } + + if !result.Replayed { + p.tellRecipient(recipient, from, currency, in.Amount) + } + balances, err := p.spendable.SpendableBalances(ctx, senderID, p.now()) + if err != nil { + return nil, err + } + result.Balance = balances[currency] + return result, nil +} + +// recipient finds who a phone number belongs to and checks the sender may send to +// them: an active customer of the same organization, not the walk-in customer, and +// not the sender. +func (p *WalletTransferProcessor) recipient(ctx context.Context, sender *repository.WalletMoveCustomer, phoneNumber string) (*repository.WalletMoveCustomer, error) { + phoneNumber = strings.TrimSpace(phoneNumber) + if phoneNumber == "" { + return nil, fmt.Errorf("%w: the recipient's phone number is required", ErrWalletMoveRejected) + } + recipient, err := p.customers.FindCustomerByPhone(ctx, phoneNumber) + if errors.Is(err, repository.ErrWalletNotFound) { + return nil, ErrWalletRecipientNotFound + } + if err != nil { + return nil, err + } + switch { + case recipient.OrganizationID != sender.OrganizationID: + return nil, ErrWalletRecipientNotFound + case recipient.ID == sender.ID: + return nil, fmt.Errorf("%w: you cannot send to yourself", ErrWalletMoveRejected) + case recipient.IsDefault || !recipient.IsActive: + return nil, fmt.Errorf("%w: this customer cannot receive transfers", ErrWalletMoveRejected) + } + return recipient, nil +} + +// tellRecipient is best effort: the transfer has already happened, so a failure to +// send the message is only logged. +func (p *WalletTransferProcessor) tellRecipient(recipient *repository.WalletMoveCustomer, sender *models.WalletTransferRecipient, currency string, amount int64) { + if p.messenger == nil || recipient.PhoneNumber == nil { + return + } + message := fmt.Sprintf("Kamu menerima %d %s dari %s (%s). Cek riwayatnya di aplikasi.", + amount, walletCurrencyName(currency), sender.Name, sender.PhoneNumber) + if err := p.messenger.SendWhatsAppMessage(*recipient.PhoneNumber, message); err != nil { + logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about a transfer", recipient.ID), err) + } +} + +func maskedRecipient(c *repository.WalletMoveCustomer) *models.WalletTransferRecipient { + phone := "" + if c.PhoneNumber != nil { + phone = maskPhoneNumber(*c.PhoneNumber) + } + return &models.WalletTransferRecipient{Name: maskName(c.Name), PhoneNumber: phone} +} + +// maskName keeps the first two letters of each word, "Budi Santoso" → "Bu*** Sa***", +// and one letter of a word that short, so the sender can recognise the recipient +// without the app revealing their name (F5, §8.1). +func maskName(name string) string { + words := strings.Fields(name) + if len(words) == 0 { + return "***" + } + for i, w := range words { + keep := 2 + if utf8.RuneCountInString(w) <= 2 { + keep = 1 + } + words[i] = string([]rune(w)[:keep]) + "***" + } + return strings.Join(words, " ") +} + +// maskPhoneNumber keeps the first two and the last four digits: +// "081234561234" → "08**-****-1234". +func maskPhoneNumber(phone string) string { + runes := []rune(strings.TrimSpace(phone)) + if len(runes) < 8 { + return "****" + } + return string(runes[:2]) + "**-****-" + string(runes[len(runes)-4:]) +} + +func walletCurrencyName(currency string) string { + if currency == constants.WalletCurrencyCoin { + return "EnakCoin" + } + return "EnakPoint" +} + +// startOfWalletDay is midnight of t's day in the customer's time zone, where the +// daily transfer limit starts over. +func startOfWalletDay(t time.Time) time.Time { + local := t.In(walletDisplayLocation) + return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, walletDisplayLocation) +} diff --git a/internal/processor/wallet_transfer_processor_test.go b/internal/processor/wallet_transfer_processor_test.go new file mode 100644 index 0000000..1aa14a9 --- /dev/null +++ b/internal/processor/wallet_transfer_processor_test.go @@ -0,0 +1,225 @@ +package processor + +import ( + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +type messengerFake struct{ sent map[string][]string } + +func (f *messengerFake) SendWhatsAppMessage(phone, message string) error { + if f.sent == nil { + f.sent = map[string][]string{} + } + f.sent[phone] = append(f.sent[phone], message) + return nil +} + +func sendPoints(amount int64, phone string) models.WalletTransfer { + return models.WalletTransfer{Currency: constants.WalletCurrencyPoint, Amount: amount, RecipientPhone: phone} +} + +func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + b := e.member("Budi Santoso", "081234561234") + dec, jan := e.at(30*24*time.Hour), e.at(60*24*time.Hour) + first := e.credit(t, earn(a, 100, dec)) + second := e.credit(t, earn(a, 50, jan)) + messenger := &messengerFake{} + + // The example in §8: A sends 120, 100 from the lot expiring first and 20 from the next. + res, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + + assert.Equal(t, int64(30), res.Balance) + assert.Equal(t, models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, res.Recipient) + assert.Equal(t, []models.WalletMovedLot{{Amount: 100, ExpiresAt: dec}, {Amount: 20, ExpiresAt: jan}}, res.Lots) + assert.Equal(t, int64(120), e.balance(t, b)) + assert.Equal(t, []PinAction{PinActionTransfer}, e.pins.actions) + + var bLots []uuid.UUID + for _, lot := range e.repo.lots { + if lot.CustomerID == b { + require.NotNil(t, lot.OriginLotID) + bLots = append(bLots, *lot.OriginLotID) + for _, origin := range e.repo.lots { + if origin.ID == *lot.OriginLotID { + assert.Equal(t, origin.ExpiresAt, lot.ExpiresAt, "the recipient's lot expires exactly when the sender's did") + } + } + } + } + assert.Equal(t, []uuid.UUID{first.Lots[0].ID, second.Lots[0].ID}, bLots) + + out, in := e.repo.transactions[2], e.repo.transactions[3] + assert.Equal(t, constants.WalletTxTypeTransferOut, out.Type) + assert.Equal(t, b, *out.CounterpartyCustomerID) + assert.Equal(t, in.ID, out.ReferenceID) + assert.Equal(t, "Transfer ke Bu*** Sa*** (08**-****-1234)", out.Description) + assert.Equal(t, constants.WalletTxTypeTransferIn, in.Type) + assert.Equal(t, a, *in.CounterpartyCustomerID) + assert.Equal(t, out.ID, in.ReferenceID) + assert.Equal(t, *out.GroupID, *in.GroupID) + assert.Equal(t, "Transfer dari An*** (08**-****-5678)", in.Description) + assert.GreaterOrEqual(t, e.repo.locks[a], 1) + assert.GreaterOrEqual(t, e.repo.locks[b], 1) + + assert.Equal(t, []string{"Kamu menerima 120 EnakPoint dari An*** (08**-****-5678). Cek riwayatnya di aplikasi."}, messenger.sent["081234561234"]) +} + +func TestWalletTransfer_Coins(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + b := e.member("Budi", "081234561234") + e.earnCoins(t, a, 10, nil) + + _, err := e.transfers(nil).Transfer(e.ctx, a, models.WalletTransfer{Currency: "coin", Amount: 4, RecipientPhone: "081234561234"}, "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + assert.Equal(t, int64(6), e.coinBalance(t, a)) + assert.Equal(t, int64(4), e.coinBalance(t, b)) +} + +func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + e.credit(t, earn(a, 100, nil)) + + walkIn := e.member("Walk-in", "081100000000") + e.customers.byID[walkIn].IsDefault = true + inactive := e.member("Old", "081100000001") + e.customers.byID[inactive].IsActive = false + elsewhere := e.member("Other Org", "081100000002") + e.customers.byID[elsewhere].OrganizationID = uuid.New() + + for phone, want := range map[string]error{ + "081200005678": ErrWalletMoveRejected, // herself + "081100000000": ErrWalletMoveRejected, // the walk-in customer + "081100000001": ErrWalletMoveRejected, // inactive + "081100000002": ErrWalletRecipientNotFound, // another organization looks like nobody + "081999999999": ErrWalletRecipientNotFound, + "": ErrWalletMoveRejected, + } { + _, err := e.transfers(nil).Recipient(e.ctx, a, phone) + assert.ErrorIs(t, err, want, phone) + _, err = e.transfers(nil).Transfer(e.ctx, a, sendPoints(10, phone), "482913", "key-"+phone, models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, want, phone) + } + assert.Empty(t, e.pins.actions, "refused before the PIN") + assert.Equal(t, int64(100), e.balance(t, a)) +} + +func TestWalletTransfer_RecipientIsMasked(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + e.member("Budi Santoso", "081234561234") + + got, err := e.transfers(nil).Recipient(e.ctx, a, " 081234561234 ") + require.NoError(t, err) + assert.Equal(t, &models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, got) +} + +func TestWalletTransfer_OrganizationLimits(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + e.member("Budi", "081234561234") + e.credit(t, earn(a, 1000, nil)) + e.settings.Transfer = models.LoyaltyTransferSettings{Enabled: true, MinAmount: 10, MaxPerTransaction: ptr(int64(300)), DailyLimit: ptr(int64(500))} + send := func(amount int64, key string) error { + _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(amount, "081234561234"), "482913", key, models.CustomerPinRequestInfo{}) + return err + } + + assert.ErrorIs(t, send(9, "below-min"), ErrWalletMoveRejected) + assert.ErrorIs(t, send(301, "above-max"), ErrWalletMoveRejected) + require.NoError(t, send(300, "k1")) + require.NoError(t, send(200, "k2")) + // The daily limit is used up; a retry of a transfer already made still replays. + assert.ErrorIs(t, send(10, "k3"), ErrWalletMoveRejected) + require.NoError(t, send(200, "k2")) + + // It starts over the next day in the customer's time zone. + e.now = startOfWalletDay(e.now).AddDate(0, 0, 1).Add(time.Minute) + e.repo.clock = e.now + require.NoError(t, send(10, "k4")) + assert.Equal(t, int64(490), e.balance(t, a)) + + e.settings.Transfer.Enabled = false + assert.ErrorIs(t, send(10, "k5"), ErrWalletMoveRejected) +} + +func TestWalletTransfer_HeldAfterPinReset(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + e.member("Budi", "081234561234") + e.credit(t, earn(a, 100, nil)) + until := e.now.Add(time.Hour) + e.pins.err = &PinError{Code: PinErrTransferBlocked, Until: &until} + + _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(10, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) + var pinErr *PinError + require.True(t, errors.As(err, &pinErr)) + assert.Equal(t, PinErrTransferBlocked, pinErr.Code) + assert.Equal(t, int64(100), e.balance(t, a)) +} + +func TestWalletTransfer_NotEnoughBalance(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + b := e.member("Budi", "081234561234") + e.credit(t, earn(a, 100, nil)) + // An expired lot cannot be sent even before the expiry job takes it. + e.credit(t, earn(a, 50, e.at(-time.Hour))) + + _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, ErrWalletMoveRejected) + assert.Equal(t, int64(0), e.balance(t, b)) +} + +func TestWalletTransfer_RetryMovesNothingAndTellsNobodyAgain(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + b := e.member("Budi", "081234561234") + e.member("Citra", "081255550000") + e.credit(t, earn(a, 100, nil)) + messenger := &messengerFake{} + + first, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + again, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + + assert.True(t, again.Replayed) + assert.Equal(t, first.GroupID, again.GroupID) + assert.Equal(t, int64(40), e.balance(t, b)) + assert.Len(t, messenger.sent["081234561234"], 1) + + // The same key to someone else is not a retry. + _, err = e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081255550000"), "482913", "key-1", models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) +} + +func TestWalletTransfer_UnknownSender(t *testing.T) { + e := newWalletMoveEnv(t) + _, err := e.transfers(nil).Transfer(e.ctx, uuid.New(), sendPoints(1, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, repository.ErrWalletNotFound) +} + +func TestMaskName(t *testing.T) { + assert.Equal(t, "Bu*** Sa***", maskName("Budi Santoso")) + assert.Equal(t, "An***", maskName("Anita")) + assert.Equal(t, "A*** Ra***", maskName("Al Rahman")) + assert.Equal(t, "***", maskName(" ")) + assert.Equal(t, "08**-****-1234", maskPhoneNumber("081234561234")) + assert.Equal(t, "+6**-****-1234", maskPhoneNumber("+6281234561234")) + assert.Equal(t, "****", maskPhoneNumber("12345")) +} diff --git a/internal/repository/wallet_move_repository.go b/internal/repository/wallet_move_repository.go index 08f983d..d8cbb70 100644 --- a/internal/repository/wallet_move_repository.go +++ b/internal/repository/wallet_move_repository.go @@ -4,9 +4,12 @@ import ( "context" "errors" "fmt" + "time" "github.com/google/uuid" "gorm.io/gorm" + + "apskel-pos-be/internal/constants" ) // WalletMoveCustomer is a customer on either side of an exchange or a transfer. @@ -25,6 +28,12 @@ type WalletMoveCustomer struct { type WalletMoveRepository interface { // GetCustomer returns ErrWalletNotFound when the customer does not exist. GetCustomer(ctx context.Context, customerID uuid.UUID) (*WalletMoveCustomer, error) + // FindCustomerByPhone returns ErrWalletNotFound when no customer has the number. + // Phone numbers are unique across organizations, so there is at most one. + FindCustomerByPhone(ctx context.Context, phoneNumber string) (*WalletMoveCustomer, error) + // TransferredOutSince sums what a customer has sent in one currency since a time. + // Call it under the sender's wallet lock, so transfers at the same time count. + TransferredOutSince(ctx context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) } type walletMoveRepository struct { @@ -39,6 +48,23 @@ func (r *walletMoveRepository) GetCustomer(ctx context.Context, customerID uuid. return r.customer(ctx, "id = ?", customerID) } +func (r *walletMoveRepository) FindCustomerByPhone(ctx context.Context, phoneNumber string) (*WalletMoveCustomer, error) { + return r.customer(ctx, "phone_number = ?", phoneNumber) +} + +func (r *walletMoveRepository) TransferredOutSince(ctx context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) { + var total int64 + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table("wallet_transactions"). + Select("COALESCE(SUM(-amount), 0)"). + Where("customer_id = ? AND currency = ? AND type = ? AND created_at >= ?", customerID, currency, constants.WalletTxTypeTransferOut, since). + Scan(&total).Error + if err != nil { + return 0, fmt.Errorf("failed to sum transfers: %w", err) + } + return total, nil +} + func (r *walletMoveRepository) customer(ctx context.Context, where string, arg interface{}) (*WalletMoveCustomer, error) { var c WalletMoveCustomer err := DBFromContext(ctx, r.db).WithContext(ctx). diff --git a/internal/router/router.go b/internal/router/router.go index 7052040..1bec687 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -173,6 +173,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode) customer.GET("/wallet/exchange/preview", r.customerWalletHandler.PreviewExchange) customer.POST("/wallet/exchange", r.customerWalletHandler.Exchange) + customer.GET("/wallet/transfer/recipient", r.customerWalletHandler.TransferRecipient) + customer.POST("/wallet/transfer", r.customerWalletHandler.Transfer) customer.POST("/orders/:id/pay-with-points", r.customerOrderPaymentHandler.PayWithPoints) // PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11) customer.GET("/pin/status", r.customerPinHandler.Status) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index a0b5daa..5887bae 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -38,6 +38,8 @@ func TestAllRoutesRegister(t *testing.T) { "POST /api/v1/customer/wallet/payment-code", "GET /api/v1/customer/wallet/exchange/preview", "POST /api/v1/customer/wallet/exchange", + "GET /api/v1/customer/wallet/transfer/recipient", + "POST /api/v1/customer/wallet/transfer", "GET /api/v1/orders/:id/point-payment/preview", "POST /api/v1/customer/orders/:id/pay-with-points", "GET /api/v1/customer/pin/status", diff --git a/internal/service/customer_wallet_service.go b/internal/service/customer_wallet_service.go index fed1450..897a32e 100644 --- a/internal/service/customer_wallet_service.go +++ b/internal/service/customer_wallet_service.go @@ -14,18 +14,42 @@ import ( ) // CustomerWalletService moves balance on the customer's own request: exchanging -// EnakCoin into EnakPoint (docs/prd-point-coin.md F4). +// EnakCoin into EnakPoint and sending either to another customer +// (docs/prd-point-coin.md F4, F5). type CustomerWalletService interface { PreviewExchange(ctx context.Context, customerID uuid.UUID, coins int64) *contract.Response Exchange(ctx context.Context, customerID uuid.UUID, req *contract.ExchangeCoinsRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response + TransferRecipient(ctx context.Context, customerID uuid.UUID, phoneNumber string) *contract.Response + Transfer(ctx context.Context, customerID uuid.UUID, req *contract.TransferWalletRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response } type CustomerWalletServiceImpl struct { exchanges *processor.WalletExchangeProcessor + transfers *processor.WalletTransferProcessor } -func NewCustomerWalletService(exchanges *processor.WalletExchangeProcessor) *CustomerWalletServiceImpl { - return &CustomerWalletServiceImpl{exchanges: exchanges} +func NewCustomerWalletService(exchanges *processor.WalletExchangeProcessor, transfers *processor.WalletTransferProcessor) *CustomerWalletServiceImpl { + return &CustomerWalletServiceImpl{exchanges: exchanges, transfers: transfers} +} + +func (s *CustomerWalletServiceImpl) TransferRecipient(ctx context.Context, customerID uuid.UUID, phoneNumber string) *contract.Response { + recipient, err := s.transfers.Recipient(ctx, customerID, phoneNumber) + if err != nil { + return walletMoveErrorResponse(err) + } + return contract.BuildSuccessResponse(recipient) +} + +func (s *CustomerWalletServiceImpl) Transfer(ctx context.Context, customerID uuid.UUID, req *contract.TransferWalletRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response { + result, err := s.transfers.Transfer(ctx, customerID, models.WalletTransfer{ + Currency: req.Currency, + Amount: req.Amount, + RecipientPhone: req.RecipientPhone, + }, req.Pin, idempotencyKey, info) + if err != nil { + return walletMoveErrorResponse(err) + } + return contract.BuildSuccessResponse(result) } func (s *CustomerWalletServiceImpl) PreviewExchange(ctx context.Context, customerID uuid.UUID, coins int64) *contract.Response { @@ -53,7 +77,8 @@ func walletMoveErrorResponse(err error) *contract.Response { } code := constants.InternalServerErrorCode switch { - case errors.Is(err, repository.ErrWalletNotFound): + case errors.Is(err, repository.ErrWalletNotFound), + errors.Is(err, processor.ErrWalletRecipientNotFound): code = constants.NotFoundErrorCode case errors.Is(err, processor.ErrWalletMoveRejected), errors.Is(err, processor.ErrWalletIdempotencyConflict), -- 2.54.0 From a18bb072f5d9fad1d5cd5662d21e6479fba5c8ee Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 12:20:42 +0700 Subject: [PATCH 36/47] feat(loyalty): pay every game with EnakCoin Games now spend the wallet's EnakCoin instead of the per-type tokens (docs/prd-point-coin.md F8, K1, PC-403). GamePlayProcessor.PlayGame charges the game's metadata.coin_cost, 1 when it is not set; a cost that is not a whole number of at least 1 refuses the game. In one transaction it picks the prize, takes the EnakCoin with a GAME_SPEND row pointing at the new game_plays.id (which locks the wallet, so a customer's plays at the same time queue up), records the play and takes the prize from stock. The play owns its transaction, so the spin service no longer wraps it, and the admin play endpoint is now atomic too. The game, game prize and game play repositories go through DBFromContext so they join that transaction. DecreaseStock now reports a prize that ran out (ErrGamePrizeOutOfStock) instead of silently updating nothing; that, or any other stock failure, cancels the whole play, where it used to be only printed. The manual AddTokens rollback is gone. Not enough EnakCoin, an inactive game or a prize that ran out answer 400 on /customer/spin instead of 500. game_plays.token_used is renamed coins_used (migration 000095). What a play costs is no longer the caller's choice, so PlayGameRequest loses token_used. Responses carry coins_used and coins_remaining; token_used and tokens_remaining stay as deprecated copies until the apps move over, and sort_by=token_used still sorts by coins_used. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 4 +- internal/contract/game_play_contract.go | 25 +- internal/contract/spin_game_contract.go | 8 +- internal/entities/game_play.go | 2 +- internal/handler/spin_game_handler.go | 11 +- internal/mappers/game_play_mapper.go | 4 +- internal/mappers/spin_game_mapper.go | 6 +- internal/models/game_play.go | 14 +- internal/models/spin_game.go | 6 +- internal/processor/game_play_processor.go | 244 ++++++++++------- .../processor/game_play_processor_db_test.go | 69 +++++ .../processor/game_play_processor_test.go | 245 ++++++++++++++++++ internal/repository/game_play_repository.go | 14 +- internal/repository/game_prize_repository.go | 31 ++- internal/repository/game_repository.go | 16 +- internal/service/spin_game_service.go | 36 +-- .../transformer/gamification_transformer.go | 9 +- internal/validator/gamification_validator.go | 8 +- ...me_plays_token_used_to_coins_used.down.sql | 2 + ...game_plays_token_used_to_coins_used.up.sql | 4 + 20 files changed, 577 insertions(+), 181 deletions(-) create mode 100644 internal/processor/game_play_processor_db_test.go create mode 100644 internal/processor/game_play_processor_test.go create mode 100644 migrations/000095_rename_game_plays_token_used_to_coins_used.down.sql create mode 100644 migrations/000095_rename_game_plays_token_used_to_coins_used.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index 68615a5..0329920 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -449,7 +449,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor tierProcessor: processor.NewTierProcessor(repos.tierRepo), gameProcessor: processor.NewGameProcessor(repos.gameRepo), gamePrizeProcessor: processor.NewGamePrizeProcessor(repos.gamePrizeRepo), - gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, repos.customerTokensRepo, repos.customerPointsRepo), + gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, processor.NewWalletProcessor(repos.walletRepo), repos.walletQueryRepo, repos.txManager), omsetTrackerProcessor: processor.NewOmsetTrackerProcessor(repos.omsetTrackerRepo), rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo), campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), @@ -556,7 +556,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con campaignService := service.NewCampaignService(processors.campaignProcessor, processors.campaignRuleProcessor) customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor) customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor) - spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager) + spinGameService := service.NewSpinGameService(processors.gamePlayProcessor) userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor) notificationService := service.NewNotificationService(processors.notificationProcessor) diff --git a/internal/contract/game_play_contract.go b/internal/contract/game_play_contract.go index 57ab50d..b32ad4f 100644 --- a/internal/contract/game_play_contract.go +++ b/internal/contract/game_play_contract.go @@ -9,15 +9,17 @@ import ( type CreateGamePlayRequest struct { GameID uuid.UUID `json:"game_id" validate:"required"` CustomerID uuid.UUID `json:"customer_id" validate:"required"` - TokenUsed int `json:"token_used" validate:"min=0"` + CoinsUsed int `json:"coins_used" validate:"min=0"` RandomSeed *string `json:"random_seed,omitempty"` } type GamePlayResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - CustomerID uuid.UUID `json:"customer_id"` - PrizeID *uuid.UUID `json:"prize_id,omitempty"` + ID uuid.UUID `json:"id"` + GameID uuid.UUID `json:"game_id"` + CustomerID uuid.UUID `json:"customer_id"` + PrizeID *uuid.UUID `json:"prize_id,omitempty"` + CoinsUsed int `json:"coins_used"` + // Deprecated: the same as CoinsUsed, kept until the apps read coins_used. TokenUsed int `json:"token_used"` RandomSeed *string `json:"random_seed,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -33,7 +35,7 @@ type ListGamePlaysRequest struct { GameID *uuid.UUID `json:"game_id"` CustomerID *uuid.UUID `json:"customer_id"` PrizeID *uuid.UUID `json:"prize_id"` - SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at token_used"` + SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"` SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"` } @@ -45,14 +47,17 @@ type PaginatedGamePlaysResponse struct { TotalPages int `json:"total_pages"` } +// PlayGameRequest plays a game for a customer. What it costs is the game's +// metadata.coin_cost (docs/prd-point-coin.md F8), never the caller's choice. type PlayGameRequest struct { GameID uuid.UUID `json:"game_id" validate:"required"` CustomerID uuid.UUID `json:"customer_id" validate:"required"` - TokenUsed int `json:"token_used" validate:"min=0"` } type PlayGameResponse struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` - TokensRemaining int64 `json:"tokens_remaining"` + GamePlay GamePlayResponse `json:"game_play"` + PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` + CoinsRemaining int64 `json:"coins_remaining"` + // Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining. + TokensRemaining int64 `json:"tokens_remaining"` } diff --git a/internal/contract/spin_game_contract.go b/internal/contract/spin_game_contract.go index e41dc41..67b50b7 100644 --- a/internal/contract/spin_game_contract.go +++ b/internal/contract/spin_game_contract.go @@ -14,7 +14,9 @@ type SpinGameResponse struct { // SpinGameResponseData contains the game play result type SpinGameResponseData struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` - TokensRemaining int64 `json:"tokens_remaining"` + GamePlay GamePlayResponse `json:"game_play"` + PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` + CoinsRemaining int64 `json:"coins_remaining"` + // Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining. + TokensRemaining int64 `json:"tokens_remaining"` } diff --git a/internal/entities/game_play.go b/internal/entities/game_play.go index 5ef61f6..026ca95 100644 --- a/internal/entities/game_play.go +++ b/internal/entities/game_play.go @@ -12,7 +12,7 @@ type GamePlay struct { GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"` CustomerID uuid.UUID `gorm:"type:uuid;not null;index" json:"customer_id" validate:"required"` PrizeID *uuid.UUID `gorm:"type:uuid" json:"prize_id,omitempty"` - TokenUsed int `gorm:"default:0" json:"token_used" validate:"min=0"` + CoinsUsed int `gorm:"column:coins_used;default:0" json:"coins_used" validate:"min=0"` RandomSeed *string `gorm:"type:varchar(255)" json:"random_seed,omitempty"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` diff --git a/internal/handler/spin_game_handler.go b/internal/handler/spin_game_handler.go index 3b218cb..b7c033d 100644 --- a/internal/handler/spin_game_handler.go +++ b/internal/handler/spin_game_handler.go @@ -1,10 +1,13 @@ package handler import ( + "errors" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" "apskel-pos-be/internal/service" "apskel-pos-be/internal/util" @@ -49,8 +52,14 @@ func (h *SpinGameHandler) PlaySpinGame(c *gin.Context) { // Play the spin game response, err := h.spinGameService.PlaySpinGame(c.Request.Context(), customerID.(string), modelReq) if err != nil { + // Not enough EnakCoin, an inactive game or a prize that just ran out is the + // customer's to fix, not a server failure. + code := constants.InternalServerErrorCode + if errors.Is(err, processor.ErrGamePlayRefused) { + code = constants.ValidationErrorCode + } util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.InternalServerErrorCode, constants.SpinGameHandlerEntity, err.Error()), + contract.NewResponseError(code, constants.SpinGameHandlerEntity, err.Error()), }), "SpinGameHandler::PlaySpinGame") return } diff --git a/internal/mappers/game_play_mapper.go b/internal/mappers/game_play_mapper.go index cf88268..659ca51 100644 --- a/internal/mappers/game_play_mapper.go +++ b/internal/mappers/game_play_mapper.go @@ -16,7 +16,7 @@ func ToGamePlayResponse(gamePlay *entities.GamePlay) *models.GamePlayResponse { GameID: gamePlay.GameID, CustomerID: gamePlay.CustomerID, PrizeID: gamePlay.PrizeID, - TokenUsed: gamePlay.TokenUsed, + CoinsUsed: gamePlay.CoinsUsed, RandomSeed: gamePlay.RandomSeed, CreatedAt: gamePlay.CreatedAt, Game: ToGameResponse(&gamePlay.Game), @@ -48,7 +48,7 @@ func ToGamePlayEntity(req *models.CreateGamePlayRequest) *entities.GamePlay { return &entities.GamePlay{ GameID: req.GameID, CustomerID: req.CustomerID, - TokenUsed: req.TokenUsed, + CoinsUsed: req.CoinsUsed, RandomSeed: req.RandomSeed, } } diff --git a/internal/mappers/spin_game_mapper.go b/internal/mappers/spin_game_mapper.go index be5b882..dd4b184 100644 --- a/internal/mappers/spin_game_mapper.go +++ b/internal/mappers/spin_game_mapper.go @@ -38,7 +38,8 @@ func SpinGameResponseDataModelToContract(data *models.SpinGameResponseData) *con return &contract.SpinGameResponseData{ GamePlay: GamePlayResponseModelToContract(&data.GamePlay), PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon), - TokensRemaining: data.TokensRemaining, + CoinsRemaining: data.CoinsRemaining, + TokensRemaining: data.CoinsRemaining, } } @@ -53,7 +54,8 @@ func GamePlayResponseModelToContract(resp *models.GamePlayResponse) contract.Gam GameID: resp.GameID, CustomerID: resp.CustomerID, PrizeID: resp.PrizeID, - TokenUsed: resp.TokenUsed, + CoinsUsed: resp.CoinsUsed, + TokenUsed: resp.CoinsUsed, RandomSeed: resp.RandomSeed, CreatedAt: resp.CreatedAt, Game: nil, // Optional field - can be populated separately if needed diff --git a/internal/models/game_play.go b/internal/models/game_play.go index 342c2c9..f2378b3 100644 --- a/internal/models/game_play.go +++ b/internal/models/game_play.go @@ -9,7 +9,7 @@ import ( type CreateGamePlayRequest struct { GameID uuid.UUID `json:"game_id" validate:"required"` CustomerID uuid.UUID `json:"customer_id" validate:"required"` - TokenUsed int `json:"token_used" validate:"min=0"` + CoinsUsed int `json:"coins_used" validate:"min=0"` RandomSeed *string `json:"random_seed,omitempty"` } @@ -18,7 +18,7 @@ type GamePlayResponse struct { GameID uuid.UUID `json:"game_id"` CustomerID uuid.UUID `json:"customer_id"` PrizeID *uuid.UUID `json:"prize_id,omitempty"` - TokenUsed int `json:"token_used"` + CoinsUsed int `json:"coins_used"` RandomSeed *string `json:"random_seed,omitempty"` CreatedAt time.Time `json:"created_at"` Game *GameResponse `json:"game,omitempty"` @@ -33,18 +33,18 @@ type ListGamePlaysQuery struct { GameID *uuid.UUID `query:"game_id"` CustomerID *uuid.UUID `query:"customer_id"` PrizeID *uuid.UUID `query:"prize_id"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at token_used"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` } +// PlayGameRequest plays a game for a customer, at the game's metadata.coin_cost. type PlayGameRequest struct { GameID uuid.UUID `json:"game_id" validate:"required"` CustomerID uuid.UUID `json:"customer_id" validate:"required"` - TokenUsed int `json:"token_used" validate:"min=0"` } type PlayGameResponse struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` - TokensRemaining int64 `json:"tokens_remaining"` + GamePlay GamePlayResponse `json:"game_play"` + PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` + CoinsRemaining int64 `json:"coins_remaining"` } diff --git a/internal/models/spin_game.go b/internal/models/spin_game.go index ae66946..a384b7a 100644 --- a/internal/models/spin_game.go +++ b/internal/models/spin_game.go @@ -20,9 +20,9 @@ type SpinGameResponse struct { // SpinGameResponseData contains the game play result type SpinGameResponseData struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` - TokensRemaining int64 `json:"tokens_remaining"` + GamePlay GamePlayResponse `json:"game_play"` + PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` + CoinsRemaining int64 `json:"coins_remaining"` } // SpinGamePrizeResponse represents a prize won in the spin game diff --git a/internal/processor/game_play_processor.go b/internal/processor/game_play_processor.go index 4a77282..15e3337 100644 --- a/internal/processor/game_play_processor.go +++ b/internal/processor/game_play_processor.go @@ -1,6 +1,7 @@ package processor import ( + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" @@ -8,33 +9,55 @@ import ( "context" "errors" "fmt" + "math" "math/rand" "time" "github.com/google/uuid" ) +// ErrGamePlayRefused wraps every reason a customer cannot play right now: the game, +// its prizes or their EnakCoin. The message says which. +var ErrGamePlayRefused = errors.New("game cannot be played") + +const ( + gameCoinCostKey = "coin_cost" + defaultGameCoinCost = 1 +) + +type gameReader interface { + GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error) +} + +type gamePrizeStock interface { + GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) + DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error +} + type GamePlayProcessor struct { - gamePlayRepo repository.GamePlayRepository - gameRepo *repository.GameRepository - gamePrizeRepo *repository.GamePrizeRepository - customerTokensRepo *repository.CustomerTokensRepository - customerPointsRepo repository.CustomerPointsRepository + gamePlayRepo repository.GamePlayRepository + gameRepo gameReader + gamePrizeRepo gamePrizeStock + wallet *WalletProcessor + spendable spendableReader + tx TxRunner } func NewGamePlayProcessor( gamePlayRepo repository.GamePlayRepository, - gameRepo *repository.GameRepository, - gamePrizeRepo *repository.GamePrizeRepository, - customerTokensRepo *repository.CustomerTokensRepository, - customerPointsRepo repository.CustomerPointsRepository, + gameRepo gameReader, + gamePrizeRepo gamePrizeStock, + wallet *WalletProcessor, + spendable spendableReader, + tx TxRunner, ) *GamePlayProcessor { return &GamePlayProcessor{ - gamePlayRepo: gamePlayRepo, - gameRepo: gameRepo, - gamePrizeRepo: gamePrizeRepo, - customerTokensRepo: customerTokensRepo, - customerPointsRepo: customerPointsRepo, + gamePlayRepo: gamePlayRepo, + gameRepo: gameRepo, + gamePrizeRepo: gamePrizeRepo, + wallet: wallet, + spendable: spendable, + tx: tx, } } @@ -76,6 +99,10 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis } offset := (query.Page - 1) * query.Limit + // The column was renamed; the old name still sorts the same way. + if query.SortBy == "token_used" { + query.SortBy = "coins_used" + } // Get game plays from repository gamePlays, total, err := p.gamePlayRepo.List( @@ -110,101 +137,140 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis }, nil } -// PlayGame handles the game playing logic +// PlayGame plays a game for a customer, paid with EnakCoin (docs/prd-point-coin.md +// F8, K1). Every kind of game spends the same EnakCoin, at the game's +// metadata.coin_cost. Taking the EnakCoin (GAME_SPEND), recording the play and taking +// the prize from stock happen in one transaction, so a play that cannot be completed, +// for instance because the prize just ran out, takes nothing and records nothing. func (p *GamePlayProcessor) PlayGame(ctx context.Context, req *models.PlayGameRequest) (*models.PlayGameResponse, error) { - // Verify game exists and is active + refuse := func(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrGamePlayRefused, fmt.Sprintf(format, args...)) + } game, err := p.gameRepo.GetByID(ctx, req.GameID) if err != nil { return nil, fmt.Errorf("game not found: %w", err) } - if !game.IsActive { - return nil, errors.New("game is not active") + return nil, refuse("game is not active") } - - // Convert GameType to TokenType - tokenType := entities.TokenType(game.Type) - - // Check if customer has enough tokens - customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType) + cost, err := gameCoinCost(game.Metadata) if err != nil { - return nil, fmt.Errorf("customer tokens not found: %w", err) + return nil, refuse("%s", err.Error()) } - if customerTokens.Balance < int64(req.TokenUsed) { - return nil, errors.New("insufficient tokens") - } - - // Deduct tokens - err = p.customerTokensRepo.DeductTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed)) - if err != nil { - return nil, fmt.Errorf("failed to deduct tokens: %w", err) - } - - // Get available prizes - availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID) - if err != nil { - return nil, fmt.Errorf("failed to get available prizes: %w", err) - } - - if len(availablePrizes) == 0 { - return nil, errors.New("no prizes available") - } - - // Convert entities to models for prize selection - prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes)) - for i, prize := range availablePrizes { - prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize) - } - - // Select prize based on weight - selectedPrize := p.selectPrizeByWeight(prizeResponses) - - // Generate random seed for audit - randomSeed := fmt.Sprintf("%d", time.Now().UnixNano()) - - // Create game play record - gamePlay := &models.CreateGamePlayRequest{ - GameID: req.GameID, - CustomerID: req.CustomerID, - TokenUsed: req.TokenUsed, - RandomSeed: &randomSeed, - } - - gamePlayEntity := mappers.ToGamePlayEntity(gamePlay) - if selectedPrize != nil { - gamePlayEntity.PrizeID = &selectedPrize.ID - } - - err = p.gamePlayRepo.Create(ctx, gamePlayEntity) - if err != nil { - // Rollback token deduction - p.customerTokensRepo.AddTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed)) - return nil, fmt.Errorf("failed to create game play: %w", err) - } - - // Decrease prize stock if prize was won - if selectedPrize != nil { - err = p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1) + var gamePlay *entities.GamePlay + var selectedPrize *models.GamePrizeResponse + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID) if err != nil { - // Log error but don't fail the transaction - fmt.Printf("Warning: failed to decrease prize stock: %v\n", err) + return fmt.Errorf("failed to get available prizes: %w", err) } - } + if len(availablePrizes) == 0 { + return refuse("no prizes available") + } + prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes)) + for i, prize := range availablePrizes { + prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize) + } + selectedPrize = p.selectPrizeByWeight(prizeResponses) - // Get updated token balance - updatedTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType) + // Random seed kept for audit. + randomSeed := fmt.Sprintf("%d", time.Now().UnixNano()) + gamePlay = &entities.GamePlay{ + ID: uuid.New(), + GameID: req.GameID, + CustomerID: req.CustomerID, + CoinsUsed: int(cost), + RandomSeed: &randomSeed, + } + if selectedPrize != nil { + gamePlay.PrizeID = &selectedPrize.ID + } + + // The EnakCoin go first: the debit locks the wallet, so plays by the same + // customer at the same time queue up instead of spending the same balance. + _, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ + CustomerID: req.CustomerID, + Currency: constants.WalletCurrencyCoin, + Type: constants.WalletTxTypeGameSpend, + Amount: cost, + ReferenceType: constants.WalletRefTypeGamePlay, + ReferenceID: gamePlay.ID, + Description: gameSpendDescription(game.Name, selectedPrize), + Metadata: entities.Metadata{"game_id": game.ID.String(), "coin_cost": cost}, + }}) + if errors.Is(err, repository.ErrWalletInsufficientBalance) { + return refuse("not enough EnakCoin: this game costs %d", cost) + } + if err != nil { + return err + } + if err := p.gamePlayRepo.Create(ctx, gamePlay); err != nil { + return err + } + if selectedPrize != nil { + err := p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1) + if errors.Is(err, repository.ErrGamePrizeOutOfStock) { + return refuse("the prize just ran out, please play again") + } + if err != nil { + return err + } + } + return nil + }) if err != nil { - return nil, fmt.Errorf("failed to get updated token balance: %w", err) + return nil, err } + balances, err := p.spendable.SpendableBalances(ctx, req.CustomerID, time.Now()) + if err != nil { + return nil, err + } return &models.PlayGameResponse{ - GamePlay: *mappers.ToGamePlayResponse(gamePlayEntity), - PrizeWon: selectedPrize, - TokensRemaining: updatedTokens.Balance, + GamePlay: *mappers.ToGamePlayResponse(gamePlay), + PrizeWon: selectedPrize, + CoinsRemaining: balances[constants.WalletCurrencyCoin], }, nil } +// gameCoinCost is what one play of a game costs, from games.metadata.coin_cost: a +// whole number of EnakCoin, 1 when it is not set (F8). +func gameCoinCost(metadata entities.Metadata) (int64, error) { + raw, ok := metadata[gameCoinCostKey] + if !ok || raw == nil { + return defaultGameCoinCost, nil + } + var cost int64 + switch v := raw.(type) { + case float64: + if v != math.Trunc(v) { + return 0, fmt.Errorf("the game's coin_cost %v is not a whole number", v) + } + cost = int64(v) + case int: + cost = int64(v) + case int64: + cost = v + default: + return 0, fmt.Errorf("the game's coin_cost %v is not a number", raw) + } + if cost < 1 { + return 0, fmt.Errorf("the game's coin_cost must be at least 1, got %d", cost) + } + return cost, nil +} + +// gameSpendDescription is what the customer's history shows for a play, frozen when it +// happens (§8.1): "Main Spin Wheel: dapat Voucher 10rb". +func gameSpendDescription(gameName string, prize *models.GamePrizeResponse) string { + description := "Main " + gameName + if prize != nil { + description += ": dapat " + prize.Name + } + return truncateRunes(description, walletDescriptionLimit) +} + // selectPrizeByWeight selects a prize based on weight distribution func (p *GamePlayProcessor) selectPrizeByWeight(prizes []models.GamePrizeResponse) *models.GamePrizeResponse { if len(prizes) == 0 { diff --git a/internal/processor/game_play_processor_db_test.go b/internal/processor/game_play_processor_db_test.go new file mode 100644 index 0000000..ef1cb6d --- /dev/null +++ b/internal/processor/game_play_processor_db_test.go @@ -0,0 +1,69 @@ +package processor + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// Plays against Postgres, so the game repositories really join the transaction: a +// refused play leaves no game_plays row and no GAME_SPEND. +func TestPlayGame_AgainstPostgres(t *testing.T) { + db, _, player, _ := walletMoveDB(t) + gameID, prizeID := uuid.New(), uuid.New() + require.NoError(t, db.Exec(`INSERT INTO games (id, name, type, metadata) VALUES (?, 'Raffle', 'RAFFLE', '{"coin_cost": 2}')`, gameID).Error) + require.NoError(t, db.Exec(`INSERT INTO game_prizes (id, game_id, name, weight, stock) VALUES (?, ?, 'Tumbler', 1, 1)`, prizeID, gameID).Error) + t.Cleanup(func() { + db.Exec(`DELETE FROM wallet_lot_allocations WHERE transaction_id IN (SELECT id FROM wallet_transactions WHERE customer_id = ?)`, player) + db.Exec(`DELETE FROM game_plays WHERE game_id = ?`, gameID) + db.Exec(`DELETE FROM game_prizes WHERE game_id = ?`, gameID) + db.Exec(`DELETE FROM games WHERE id = ?`, gameID) + }) + + wallet := NewWalletProcessor(repository.NewWalletRepository(db)) + txm := repository.NewTxManager(db) + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + in := earn(player, 3, nil) + in.Currency = constants.WalletCurrencyCoin + _, err := wallet.Credit(ctx, in) + return err + })) + p := NewGamePlayProcessor(repository.NewGamePlayRepository(db), repository.NewGameRepository(db), repository.NewGamePrizeRepository(db), + wallet, repository.NewWalletQueryRepository(db), txm) + play := func() (*models.PlayGameResponse, error) { + return p.PlayGame(context.Background(), &models.PlayGameRequest{GameID: gameID, CustomerID: player}) + } + + res, err := play() + require.NoError(t, err) + assert.Equal(t, int64(1), res.CoinsRemaining) + assert.Equal(t, 2, res.GamePlay.CoinsUsed) + + var spend struct { + ReferenceID uuid.UUID + Amount int64 + } + require.NoError(t, db.Raw(`SELECT reference_id, amount FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spend).Error) + assert.Equal(t, res.GamePlay.ID, spend.ReferenceID) + assert.Equal(t, int64(-2), spend.Amount) + + // The only prize is gone, then the EnakCoin are too short: neither play leaves a row. + _, err = play() + assert.ErrorIs(t, err, ErrGamePlayRefused) + require.NoError(t, db.Exec(`UPDATE game_prizes SET stock = 5 WHERE id = ?`, prizeID).Error) + _, err = play() + assert.ErrorIs(t, err, ErrGamePlayRefused) + + var plays, spends int64 + require.NoError(t, db.Raw(`SELECT COUNT(*) FROM game_plays WHERE game_id = ? AND coins_used = 2`, gameID).Scan(&plays).Error) + require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spends).Error) + assert.Equal(t, int64(1), plays) + assert.Equal(t, int64(1), spends) +} diff --git a/internal/processor/game_play_processor_test.go b/internal/processor/game_play_processor_test.go new file mode 100644 index 0000000..6a43670 --- /dev/null +++ b/internal/processor/game_play_processor_test.go @@ -0,0 +1,245 @@ +package processor + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// snapshot saves everything the fake wallet holds and returns what puts it back, so +// a test transaction can roll back like a real one. +func (f *walletRepoFake) snapshot() func() { + wallets := make(map[uuid.UUID]*entities.CustomerWallet, len(f.wallets)) + for id, w := range f.wallets { + c := *w + wallets[id] = &c + } + transactions := make([]*entities.WalletTransaction, len(f.transactions)) + for i, t := range f.transactions { + c := *t + transactions[i] = &c + } + lots := make([]*entities.WalletLot, len(f.lots)) + for i, l := range f.lots { + c := *l + lots[i] = &c + } + allocations := append([]entities.WalletLotAllocation(nil), f.allocations...) + return func() { + f.wallets, f.transactions, f.lots, f.allocations = wallets, transactions, lots, allocations + } +} + +// rollbackTx runs fn in place and, when it fails, puts back what each save saved. +type rollbackTx struct{ saves []func() func() } + +func (r rollbackTx) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error { + restores := make([]func(), 0, len(r.saves)) + for _, save := range r.saves { + restores = append(restores, save()) + } + err := fn(ctx) + if err != nil { + for _, restore := range restores { + restore() + } + } + return err +} + +type gameFake struct{ games map[uuid.UUID]*entities.Game } + +func (f gameFake) GetByID(_ context.Context, id uuid.UUID) (*entities.Game, error) { + g, ok := f.games[id] + if !ok { + return nil, errors.New("record not found") + } + return g, nil +} + +type prizeStockFake struct { + prizes []entities.GamePrize + stockErr error +} + +func (f *prizeStockFake) GetAvailablePrizes(_ context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) { + var out []entities.GamePrize + for _, p := range f.prizes { + if p.GameID == gameID && p.Stock > 0 { + out = append(out, p) + } + } + return out, nil +} + +func (f *prizeStockFake) DecreaseStock(_ context.Context, id uuid.UUID, amount int) error { + if f.stockErr != nil { + return f.stockErr + } + for i := range f.prizes { + if f.prizes[i].ID == id { + if f.prizes[i].Stock < amount { + return repository.ErrGamePrizeOutOfStock + } + f.prizes[i].Stock -= amount + return nil + } + } + return repository.ErrGamePrizeOutOfStock +} + +func (f *prizeStockFake) snapshot() func() { + prizes := append([]entities.GamePrize(nil), f.prizes...) + return func() { f.prizes = prizes } +} + +// gamePlayRepoFake records plays; the other methods of the interface are not used. +type gamePlayRepoFake struct { + repository.GamePlayRepository + plays []entities.GamePlay +} + +func (f *gamePlayRepoFake) Create(_ context.Context, gp *entities.GamePlay) error { + f.plays = append(f.plays, *gp) + return nil +} + +func (f *gamePlayRepoFake) snapshot() func() { + plays := append([]entities.GamePlay(nil), f.plays...) + return func() { f.plays = plays } +} + +type gameTestEnv struct { + *walletMoveEnv + game *entities.Game + prize entities.GamePrize + stock *prizeStockFake + plays *gamePlayRepoFake + player uuid.UUID + p *GamePlayProcessor +} + +func newGameTestEnv(t *testing.T, metadata entities.Metadata) *gameTestEnv { + e := &gameTestEnv{walletMoveEnv: newWalletMoveEnv(t)} + e.game = &entities.Game{ID: uuid.New(), Name: "Spin Wheel", Type: entities.GameTypeSpin, IsActive: true, Metadata: metadata} + e.prize = entities.GamePrize{ID: uuid.New(), GameID: e.game.ID, Name: "Voucher 10rb", Weight: 1, Stock: 5} + e.stock = &prizeStockFake{prizes: []entities.GamePrize{e.prize}} + e.plays = &gamePlayRepoFake{} + e.player = e.member("Budi", "081234561234") + tx := rollbackTx{saves: []func() func(){e.repo.snapshot, e.stock.snapshot, e.plays.snapshot}} + e.p = NewGamePlayProcessor(e.plays, gameFake{games: map[uuid.UUID]*entities.Game{e.game.ID: e.game}}, e.stock, e.walletTestEnv.p, e, tx) + return e +} + +func (e *gameTestEnv) play() (*models.PlayGameResponse, error) { + return e.p.PlayGame(e.ctx, &models.PlayGameRequest{GameID: e.game.ID, CustomerID: e.player}) +} + +func TestPlayGame_SpendsTheGamesCoinCost(t *testing.T) { + // coin_cost comes back from JSONB as a float64. + e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)}) + e.earnCoins(t, e.player, 10, nil) + + res, err := e.play() + require.NoError(t, err) + + assert.Equal(t, int64(7), res.CoinsRemaining) + require.NotNil(t, res.PrizeWon) + assert.Equal(t, e.prize.ID, res.PrizeWon.ID) + require.Len(t, e.plays.plays, 1) + play := e.plays.plays[0] + assert.Equal(t, 3, play.CoinsUsed) + assert.Equal(t, e.prize.ID, *play.PrizeID) + assert.Equal(t, 4, e.stock.prizes[0].Stock) + + spend := e.repo.transactions[len(e.repo.transactions)-1] + assert.Equal(t, constants.WalletTxTypeGameSpend, spend.Type) + assert.Equal(t, constants.WalletCurrencyCoin, spend.Currency) + assert.Equal(t, int64(-3), spend.Amount) + assert.Equal(t, constants.WalletRefTypeGamePlay, spend.ReferenceType) + assert.Equal(t, play.ID, spend.ReferenceID, "GAME_SPEND points at the game play") + assert.Equal(t, "Main Spin Wheel: dapat Voucher 10rb", spend.Description) +} + +func TestPlayGame_CostsOneCoinByDefault(t *testing.T) { + e := newGameTestEnv(t, entities.Metadata{}) + e.earnCoins(t, e.player, 2, nil) + + res, err := e.play() + require.NoError(t, err) + assert.Equal(t, int64(1), res.CoinsRemaining) + assert.Equal(t, 1, e.plays.plays[0].CoinsUsed) +} + +func TestPlayGame_NotEnoughCoinsRecordsNothing(t *testing.T) { + e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)}) + e.earnCoins(t, e.player, 2, nil) + + _, err := e.play() + assert.ErrorIs(t, err, ErrGamePlayRefused) + assert.Empty(t, e.plays.plays) + assert.Equal(t, 5, e.stock.prizes[0].Stock) + assert.Equal(t, int64(2), e.coinBalance(t, e.player)) +} + +func TestPlayGame_PrizeStockFailureCancelsThePlay(t *testing.T) { + e := newGameTestEnv(t, entities.Metadata{}) + e.earnCoins(t, e.player, 5, nil) + rows := len(e.repo.transactions) + + // Another play took the last one between reading the prizes and taking it. + e.stock.stockErr = repository.ErrGamePrizeOutOfStock + _, err := e.play() + assert.ErrorIs(t, err, ErrGamePlayRefused) + + // Anything else going wrong with the stock cancels it too. + e.stock.stockErr = errors.New("connection reset") + _, err = e.play() + assert.Error(t, err) + + assert.Empty(t, e.plays.plays) + assert.Len(t, e.repo.transactions, rows, "no GAME_SPEND is left behind") + assert.Equal(t, int64(5), e.coinBalance(t, e.player)) +} + +func TestPlayGame_RefusesWhatCannotBePlayed(t *testing.T) { + for name, metadata := range map[string]entities.Metadata{ + "zero": {"coin_cost": float64(0)}, + "fraction": {"coin_cost": 1.5}, + "not number": {"coin_cost": "2"}, + } { + t.Run(name, func(t *testing.T) { + e := newGameTestEnv(t, metadata) + e.earnCoins(t, e.player, 5, nil) + _, err := e.play() + assert.ErrorIs(t, err, ErrGamePlayRefused) + assert.Equal(t, int64(5), e.coinBalance(t, e.player)) + }) + } + + t.Run("inactive", func(t *testing.T) { + e := newGameTestEnv(t, entities.Metadata{}) + e.earnCoins(t, e.player, 5, nil) + e.game.IsActive = false + _, err := e.play() + assert.ErrorIs(t, err, ErrGamePlayRefused) + }) + + t.Run("no prizes left", func(t *testing.T) { + e := newGameTestEnv(t, entities.Metadata{}) + e.earnCoins(t, e.player, 5, nil) + e.stock.prizes[0].Stock = 0 + _, err := e.play() + assert.ErrorIs(t, err, ErrGamePlayRefused) + assert.Equal(t, int64(5), e.coinBalance(t, e.player)) + }) +} diff --git a/internal/repository/game_play_repository.go b/internal/repository/game_play_repository.go index b3ffb2c..48c8d01 100644 --- a/internal/repository/game_play_repository.go +++ b/internal/repository/game_play_repository.go @@ -31,7 +31,7 @@ func NewGamePlayRepository(db *gorm.DB) GamePlayRepository { } func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.GamePlay) error { - if err := r.db.WithContext(ctx).Create(gamePlay).Error; err != nil { + if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePlay).Error; err != nil { return fmt.Errorf("failed to create game play: %w", err) } return nil @@ -39,7 +39,7 @@ func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.Game func (r *gamePlayRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePlay, error) { var gamePlay entities.GamePlay - err := r.db.WithContext(ctx). + err := DBFromContext(ctx, r.db).WithContext(ctx). Preload("Game"). Preload("Customer"). Preload("Prize"). @@ -57,7 +57,7 @@ func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search var gamePlays []*entities.GamePlay var total int64 - query := r.db.WithContext(ctx).Model(&entities.GamePlay{}) + query := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePlay{}) // Apply filters if search != "" { @@ -107,7 +107,7 @@ func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID, limit int) ([]*entities.GamePlay, error) { var gamePlays []*entities.GamePlay - query := r.db.WithContext(ctx). + query := DBFromContext(ctx, r.db).WithContext(ctx). Where("customer_id = ?", customerID). Preload("Game"). Preload("Prize"). @@ -127,7 +127,7 @@ func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uui func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID, limit int) ([]*entities.GamePlay, error) { var gamePlays []*entities.GamePlay - query := r.db.WithContext(ctx). + query := DBFromContext(ctx, r.db).WithContext(ctx). Where("game_id = ?", gameID). Preload("Customer"). Preload("Prize"). @@ -147,7 +147,7 @@ func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID, func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID uuid.UUID) (int64, error) { var count int64 - err := r.db.WithContext(ctx). + err := DBFromContext(ctx, r.db).WithContext(ctx). Model(&entities.GamePlay{}). Where("customer_id = ?", customerID). Count(&count).Error @@ -159,7 +159,7 @@ func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID u func (r *gamePlayRepository) CountByGameID(ctx context.Context, gameID uuid.UUID) (int64, error) { var count int64 - err := r.db.WithContext(ctx). + err := DBFromContext(ctx, r.db).WithContext(ctx). Model(&entities.GamePlay{}). Where("game_id = ?", gameID). Count(&count).Error diff --git a/internal/repository/game_prize_repository.go b/internal/repository/game_prize_repository.go index 243bf73..2b2f13c 100644 --- a/internal/repository/game_prize_repository.go +++ b/internal/repository/game_prize_repository.go @@ -3,12 +3,16 @@ package repository import ( "apskel-pos-be/internal/entities" "context" + "errors" "fmt" "github.com/google/uuid" "gorm.io/gorm" ) +// ErrGamePrizeOutOfStock means a prize had less stock than was taken from it. +var ErrGamePrizeOutOfStock = errors.New("game prize is out of stock") + type GamePrizeRepository struct { db *gorm.DB } @@ -18,12 +22,12 @@ func NewGamePrizeRepository(db *gorm.DB) *GamePrizeRepository { } func (r *GamePrizeRepository) Create(ctx context.Context, gamePrize *entities.GamePrize) error { - return r.db.WithContext(ctx).Create(gamePrize).Error + return DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePrize).Error } func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePrize, error) { var gamePrize entities.GamePrize - err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("id = ?", id).First(&gamePrize).Error + err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("id = ?", id).First(&gamePrize).Error if err != nil { return nil, err } @@ -32,7 +36,7 @@ func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entit func (r *GamePrizeRepository) GetByGameID(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) { var gamePrizes []entities.GamePrize - err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("game_id = ?", gameID).Find(&gamePrizes).Error + err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("game_id = ?", gameID).Find(&gamePrizes).Error if err != nil { return nil, err } @@ -43,7 +47,7 @@ func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, searc var gamePrizes []entities.GamePrize var total int64 - query := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize") + query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize") if search != "" { searchTerm := "%" + search + "%" @@ -76,22 +80,31 @@ func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, searc } func (r *GamePrizeRepository) Update(ctx context.Context, gamePrize *entities.GamePrize) error { - return r.db.WithContext(ctx).Save(gamePrize).Error + return DBFromContext(ctx, r.db).WithContext(ctx).Save(gamePrize).Error } func (r *GamePrizeRepository) Delete(ctx context.Context, id uuid.UUID) error { - return r.db.WithContext(ctx).Delete(&entities.GamePrize{}, id).Error + return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.GamePrize{}, id).Error } +// DecreaseStock takes amount from a prize's stock. When less than amount is left it +// changes nothing and returns ErrGamePrizeOutOfStock. func (r *GamePrizeRepository) DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error { - return r.db.WithContext(ctx).Model(&entities.GamePrize{}). + res := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePrize{}). Where("id = ? AND stock >= ?", id, amount). - Update("stock", gorm.Expr("stock - ?", amount)).Error + Update("stock", gorm.Expr("stock - ?", amount)) + if res.Error != nil { + return fmt.Errorf("failed to decrease prize stock: %w", res.Error) + } + if res.RowsAffected == 0 { + return ErrGamePrizeOutOfStock + } + return nil } func (r *GamePrizeRepository) GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) { var gamePrizes []entities.GamePrize - err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize"). + err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize"). Where("game_id = ? AND stock > 0", gameID). Order("weight DESC"). Find(&gamePrizes).Error diff --git a/internal/repository/game_repository.go b/internal/repository/game_repository.go index 4d41f18..22d281a 100644 --- a/internal/repository/game_repository.go +++ b/internal/repository/game_repository.go @@ -18,12 +18,12 @@ func NewGameRepository(db *gorm.DB) *GameRepository { } func (r *GameRepository) Create(ctx context.Context, game *entities.Game) error { - return r.db.WithContext(ctx).Create(game).Error + return DBFromContext(ctx, r.db).WithContext(ctx).Create(game).Error } func (r *GameRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error) { var game entities.Game - err := r.db.WithContext(ctx).Preload("Prizes").Where("id = ?", id).First(&game).Error + err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("id = ?", id).First(&game).Error if err != nil { return nil, err } @@ -34,7 +34,7 @@ func (r *GameRepository) List(ctx context.Context, offset, limit int, search, ga var games []entities.Game var total int64 - query := r.db.WithContext(ctx).Preload("Prizes") + query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes") if search != "" { searchTerm := "%" + search + "%" @@ -71,16 +71,16 @@ func (r *GameRepository) List(ctx context.Context, offset, limit int, search, ga } func (r *GameRepository) Update(ctx context.Context, game *entities.Game) error { - return r.db.WithContext(ctx).Save(game).Error + return DBFromContext(ctx, r.db).WithContext(ctx).Save(game).Error } func (r *GameRepository) Delete(ctx context.Context, id uuid.UUID) error { - return r.db.WithContext(ctx).Delete(&entities.Game{}, id).Error + return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.Game{}, id).Error } func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, error) { var games []entities.Game - err := r.db.WithContext(ctx).Preload("Prizes").Where("is_active = ?", true).Find(&games).Error + err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("is_active = ?", true).Find(&games).Error if err != nil { return nil, err } @@ -89,7 +89,7 @@ func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, e func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Game, error) { var games []entities.Game - err := r.db.WithContext(ctx). + err := DBFromContext(ctx, r.db).WithContext(ctx). Preload("Prizes"). Where("is_active = ? AND type = ?", true, entities.GameTypeSpin). Find(&games).Error @@ -101,7 +101,7 @@ func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Gam func (r *GameRepository) GetFerrisWheelGame(ctx context.Context) (*entities.Game, error) { var game entities.Game - err := r.db.WithContext(ctx). + err := DBFromContext(ctx, r.db).WithContext(ctx). Preload("Prizes"). Where("is_active = ? AND type = ?", true, "SPIN"). First(&game).Error diff --git a/internal/service/spin_game_service.go b/internal/service/spin_game_service.go index dcfd93e..42390c2 100644 --- a/internal/service/spin_game_service.go +++ b/internal/service/spin_game_service.go @@ -6,7 +6,6 @@ import ( "apskel-pos-be/internal/models" "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" "github.com/google/uuid" ) @@ -17,13 +16,11 @@ type SpinGameService interface { type spinGameService struct { gamePlayProcessor *processor.GamePlayProcessor - txManager *repository.TxManager } -func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor, txManager *repository.TxManager) SpinGameService { +func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor) SpinGameService { return &spinGameService{ gamePlayProcessor: gamePlayProcessor, - txManager: txManager, } } @@ -45,28 +42,13 @@ func (s *spinGameService) PlaySpinGame(ctx context.Context, customerID string, r return nil, fmt.Errorf("invalid customer ID format: %w", err) } - // Use transaction to ensure consistency - var playResponse *models.PlayGameResponse - err = s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - // Create play game request - playGameReq := &models.PlayGameRequest{ - GameID: spinID, - CustomerID: customerUUID, - TokenUsed: 1, // Default to 1 token per spin - } - - // Play the game within transaction - var playErr error - playResponse, playErr = s.gamePlayProcessor.PlayGame(txCtx, playGameReq) - if playErr != nil { - return fmt.Errorf("failed to play spin game: %w", playErr) - } - - return nil + // PlayGame runs its own transaction and charges the game's coin_cost in EnakCoin. + playResponse, err := s.gamePlayProcessor.PlayGame(ctx, &models.PlayGameRequest{ + GameID: spinID, + CustomerID: customerUUID, }) - if err != nil { - return nil, err + return nil, fmt.Errorf("failed to play spin game: %w", err) } // Convert prize to customer-facing format @@ -88,9 +70,9 @@ func (s *spinGameService) PlaySpinGame(ctx context.Context, customerID string, r Status: "SUCCESS", Message: "Spin game completed successfully.", Data: &models.SpinGameResponseData{ - GamePlay: playResponse.GamePlay, - PrizeWon: customerPrize, - TokensRemaining: playResponse.TokensRemaining, + GamePlay: playResponse.GamePlay, + PrizeWon: customerPrize, + CoinsRemaining: playResponse.CoinsRemaining, }, } diff --git a/internal/transformer/gamification_transformer.go b/internal/transformer/gamification_transformer.go index b7a2b70..a1a0dcd 100644 --- a/internal/transformer/gamification_transformer.go +++ b/internal/transformer/gamification_transformer.go @@ -350,7 +350,7 @@ func CreateGamePlayRequestToModel(req *contract.CreateGamePlayRequest) *models.C return &models.CreateGamePlayRequest{ GameID: req.GameID, CustomerID: req.CustomerID, - TokenUsed: req.TokenUsed, + CoinsUsed: req.CoinsUsed, RandomSeed: req.RandomSeed, } } @@ -359,7 +359,6 @@ func PlayGameRequestToModel(req *contract.PlayGameRequest) *models.PlayGameReque return &models.PlayGameRequest{ GameID: req.GameID, CustomerID: req.CustomerID, - TokenUsed: req.TokenUsed, } } @@ -401,7 +400,8 @@ func GamePlayModelToResponse(model *models.GamePlayResponse) *contract.GamePlayR GameID: model.GameID, CustomerID: model.CustomerID, PrizeID: model.PrizeID, - TokenUsed: model.TokenUsed, + CoinsUsed: model.CoinsUsed, + TokenUsed: model.CoinsUsed, RandomSeed: model.RandomSeed, CreatedAt: model.CreatedAt, Game: game, @@ -433,7 +433,8 @@ func PlayGameModelToResponse(model *models.PlayGameResponse) *contract.PlayGameR return &contract.PlayGameResponse{ GamePlay: gamePlayValue, PrizeWon: prizeWon, - TokensRemaining: model.TokensRemaining, + CoinsRemaining: model.CoinsRemaining, + TokensRemaining: model.CoinsRemaining, } } diff --git a/internal/validator/gamification_validator.go b/internal/validator/gamification_validator.go index c65a001..2ce5a44 100644 --- a/internal/validator/gamification_validator.go +++ b/internal/validator/gamification_validator.go @@ -436,8 +436,8 @@ func (v *GamificationValidatorImpl) ValidateCreateGamePlayRequest(req *contract. return err, "VALIDATION_ERROR" } - if req.TokenUsed < 0 { - return errors.New("token used cannot be negative"), "INVALID_TOKEN_USED" + if req.CoinsUsed < 0 { + return errors.New("coins used cannot be negative"), "INVALID_COINS_USED" } return nil, "" @@ -466,10 +466,6 @@ func (v *GamificationValidatorImpl) ValidatePlayGameRequest(req *contract.PlayGa return err, "VALIDATION_ERROR" } - if req.TokenUsed < 0 { - return errors.New("token used cannot be negative"), "INVALID_TOKEN_USED" - } - return nil, "" } diff --git a/migrations/000095_rename_game_plays_token_used_to_coins_used.down.sql b/migrations/000095_rename_game_plays_token_used_to_coins_used.down.sql new file mode 100644 index 0000000..fb9fb88 --- /dev/null +++ b/migrations/000095_rename_game_plays_token_used_to_coins_used.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE game_plays RENAME CONSTRAINT chk_game_plays_coins_used_non_negative TO chk_game_plays_token_used_non_negative; +ALTER TABLE game_plays RENAME COLUMN coins_used TO token_used; diff --git a/migrations/000095_rename_game_plays_token_used_to_coins_used.up.sql b/migrations/000095_rename_game_plays_token_used_to_coins_used.up.sql new file mode 100644 index 0000000..d59c7e1 --- /dev/null +++ b/migrations/000095_rename_game_plays_token_used_to_coins_used.up.sql @@ -0,0 +1,4 @@ +-- Every game now costs EnakCoin (docs/prd-point-coin.md F8, K1), so what a play used +-- is a number of EnakCoin. +ALTER TABLE game_plays RENAME COLUMN token_used TO coins_used; +ALTER TABLE game_plays RENAME CONSTRAINT chk_game_plays_token_used_non_negative TO chk_game_plays_coins_used_non_negative; -- 2.54.0 From 8bf1d5c1a874e74859e30b8cf3753e7b8e6c1ce5 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 12:25:17 +0700 Subject: [PATCH 37/47] feat(loyalty): trace a wallet row lot by lot in the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /marketing/wallet-transactions/:id/trace (docs/prd-point-coin.md F7, §8.1, PC-404). From any ledger row of the organization, the trace lists the lots a debit took from, with how much it took from each, or the lots a credit created. Each lot is followed back through origin_lot_id, across transfers, exchanges and refunds, to the lot an EARN, ADJUSTMENT or MIGRATION first created. Every step shows the lot and the row that created it, with the real name of the customer it belongs to, so the example of §8 (A sends 120 to B, B pays 30) leads from B's payment to A's order #ORD-1. Lots are loaded a generation at a time, and a chain stops at 100 steps or at a lot it has already seen, which only bad data could cause. A row of another organization answers 404. The dashboard's wallet view now builds its lots with the same helper. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 4 +- internal/handler/wallet_admin_db_test.go | 2 +- internal/handler/wallet_admin_handler.go | 14 ++ internal/models/wallet_trace.go | 46 +++++ internal/processor/wallet_admin_processor.go | 12 +- internal/processor/wallet_move_db_test.go | 41 ++++ internal/processor/wallet_trace_processor.go | 180 ++++++++++++++++++ .../processor/wallet_trace_processor_test.go | 161 ++++++++++++++++ .../repository/wallet_trace_repository.go | 109 +++++++++++ internal/router/router.go | 2 + internal/router/router_test.go | 1 + internal/service/wallet_admin_service.go | 17 +- 12 files changed, 574 insertions(+), 15 deletions(-) create mode 100644 internal/models/wallet_trace.go create mode 100644 internal/processor/wallet_trace_processor.go create mode 100644 internal/processor/wallet_trace_processor_test.go create mode 100644 internal/repository/wallet_trace_repository.go diff --git a/internal/app/app.go b/internal/app/app.go index 0329920..63437f9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -395,6 +395,7 @@ type processors struct { pointPaymentProcessor *processor.PointPaymentProcessor walletExchangeProcessor *processor.WalletExchangeProcessor walletTransferProcessor *processor.WalletTransferProcessor + walletTraceProcessor *processor.WalletTraceProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -472,6 +473,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor pointPaymentProcessor: pointPaymentProcessor, walletExchangeProcessor: walletExchangeProcessor, walletTransferProcessor: walletTransferProcessor, + walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)), walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), } } @@ -602,7 +604,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor), expenseService: service.NewExpenseService(processors.expenseProcessor), cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor), - walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor), + walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor, processors.walletTraceProcessor), loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo), customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor), pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor), diff --git a/internal/handler/wallet_admin_db_test.go b/internal/handler/wallet_admin_db_test.go index 1e1a690..5ca8677 100644 --- a/internal/handler/wallet_admin_db_test.go +++ b/internal/handler/wallet_admin_db_test.go @@ -74,7 +74,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) { })) h := NewWalletAdminHandler( - service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm)), + service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))), validator.NewWalletValidator(), ) gin.SetMode(gin.TestMode) diff --git a/internal/handler/wallet_admin_handler.go b/internal/handler/wallet_admin_handler.go index ff2311e..648cbb5 100644 --- a/internal/handler/wallet_admin_handler.go +++ b/internal/handler/wallet_admin_handler.go @@ -83,3 +83,17 @@ func (h *WalletAdminHandler) AdjustCustomerWallet(c *gin.Context) { } util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::AdjustCustomerWallet") } + +// TraceTransaction is GET /marketing/wallet-transactions/:id/trace. +func (h *WalletAdminHandler) TraceTransaction(c *gin.Context) { + ctx := c.Request.Context() + transactionID, ok := parseUUIDParam(c, "id", "WalletAdminHandler::TraceTransaction") + if !ok { + return + } + response := h.walletAdminService.TraceTransaction(ctx, appcontext.FromGinContext(ctx), transactionID) + if response.HasErrors() { + logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::TraceTransaction -> service call failed") + } + util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::TraceTransaction") +} diff --git a/internal/models/wallet_trace.go b/internal/models/wallet_trace.go new file mode 100644 index 0000000..809d7c9 --- /dev/null +++ b/internal/models/wallet_trace.go @@ -0,0 +1,46 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// AdminWalletTrace is GET /marketing/wallet-transactions/:id/trace +// (docs/prd-point-coin.md F7, §8.1): the lots a ledger row took from or created, and +// where each of them came from, lot by lot, back to the lot that first brought the +// balance into a wallet. It follows balance across transfers, exchanges and refunds. +type AdminWalletTrace struct { + Transaction AdminWalletTraceRow `json:"transaction"` + // One per lot the row took from (a debit) or created (a credit). + Lots []AdminWalletTraceLot `json:"lots"` +} + +// AdminWalletTraceLot is how much of a row went through one lot, and that lot's chain. +type AdminWalletTraceLot struct { + Amount int64 `json:"amount"` + // The lot itself first, then the lot it was carried over from, and so on. The + // last step is the lot an EARN, ADJUSTMENT or MIGRATION created. + Chain []AdminWalletTraceStep `json:"chain"` +} + +// AdminWalletTraceStep is one lot on a chain and the row that created it. +type AdminWalletTraceStep struct { + Lot AdminWalletLot `json:"lot"` + Source AdminWalletTraceRow `json:"source"` +} + +// AdminWalletTraceRow is a ledger row as a trace shows it, with the real name of the +// customer it belongs to. +type AdminWalletTraceRow struct { + ID uuid.UUID `json:"id"` + Customer AdminWalletNamedRef `json:"customer"` + Currency string `json:"currency"` + Type string `json:"type"` + Amount int64 `json:"amount"` + Description string `json:"description"` + ReferenceType string `json:"reference_type"` + ReferenceID uuid.UUID `json:"reference_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/processor/wallet_admin_processor.go b/internal/processor/wallet_admin_processor.go index 86483f7..d48c495 100644 --- a/internal/processor/wallet_admin_processor.go +++ b/internal/processor/wallet_admin_processor.go @@ -69,17 +69,7 @@ func (p *WalletAdminProcessor) GetCustomerWallet(ctx context.Context, organizati return nil, err } for _, lot := range lots { - result.Lots = append(result.Lots, models.AdminWalletLot{ - ID: lot.ID, - Currency: lot.Currency, - OriginalAmount: lot.OriginalAmount, - RemainingAmount: lot.RemainingAmount, - ExpiresAt: lot.ExpiresAt, - Expired: lot.ExpiresAt != nil && !lot.ExpiresAt.After(now), - SourceTransactionID: lot.SourceTransactionID, - OriginLotID: lot.OriginLotID, - CreatedAt: lot.CreatedAt, - }) + result.Lots = append(result.Lots, adminWalletLot(lot, now)) } rows, total, err := p.query.ListTransactions(ctx, filter) diff --git a/internal/processor/wallet_move_db_test.go b/internal/processor/wallet_move_db_test.go index a7d3690..0f654d5 100644 --- a/internal/processor/wallet_move_db_test.go +++ b/internal/processor/wallet_move_db_test.go @@ -155,3 +155,44 @@ func TestWalletTransfer_BothWaysAtOnceAgainstPostgres(t *testing.T) { return err })) } + +// The example of §8 against Postgres: B's payment of 30 traces back to A's #ORD-1. +func TestWalletTrace_AgainstPostgres(t *testing.T) { + db, org, a, b := walletMoveDB(t) + wallet := NewWalletProcessor(repository.NewWalletRepository(db)) + txm := repository.NewTxManager(db) + settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{ + Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1}, + }} + transfers := NewWalletTransferProcessor(repository.NewWalletMoveRepository(db), settings, repository.NewWalletQueryRepository(db), &movePinFake{good: "482913"}, wallet, txm, nil) + + dec, jan := time.Now().Add(30*24*time.Hour), time.Now().Add(60*24*time.Hour) + ord1 := earn(a, 100, &dec) + ord1.Description = "Belanja #ORD-1" + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + if _, err := wallet.Credit(ctx, ord1); err != nil { + return err + } + _, err := wallet.Credit(ctx, earn(a, 50, &jan)) + return err + })) + _, err := transfers.Transfer(context.Background(), a, sendPoints(120, "08"+b.String()[:10]), "482913", "trace", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + var payment *WalletResult + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + payment, err = wallet.Debit(ctx, pay(b, 30)) + return err + })) + + trace, err := NewWalletTraceProcessor(repository.NewWalletTraceRepository(db)).Trace(context.Background(), org, payment.Transaction.ID) + require.NoError(t, err) + require.Len(t, trace.Lots, 1) + chain := trace.Lots[0].Chain + require.Len(t, chain, 2) + assert.Equal(t, constants.WalletTxTypeTransferIn, chain[0].Source.Type) + assert.Equal(t, "Anita", chain[1].Source.Customer.Name) + assert.Equal(t, ord1.ReferenceID, chain[1].Source.ReferenceID) + + _, err = NewWalletTraceProcessor(repository.NewWalletTraceRepository(db)).Trace(context.Background(), uuid.New(), payment.Transaction.ID) + assert.ErrorIs(t, err, repository.ErrWalletTransactionNotFound) +} diff --git a/internal/processor/wallet_trace_processor.go b/internal/processor/wallet_trace_processor.go new file mode 100644 index 0000000..dc37e60 --- /dev/null +++ b/internal/processor/wallet_trace_processor.go @@ -0,0 +1,180 @@ +package processor + +import ( + "context" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// walletTraceMaxDepth bounds how far back a chain is followed. Every step is a +// transfer, exchange or refund of the step before, so real chains are short; the +// bound only guards against a loop in bad data. +const walletTraceMaxDepth = 100 + +// WalletTraceProcessor traces a ledger row lot by lot for the dashboard +// (docs/prd-point-coin.md F7, §8.1). It only reads. +type WalletTraceProcessor struct { + repo repository.WalletTraceRepository + now func() time.Time +} + +func NewWalletTraceProcessor(repo repository.WalletTraceRepository) *WalletTraceProcessor { + return &WalletTraceProcessor{repo: repo, now: time.Now} +} + +type walletTraceStart struct { + lotID uuid.UUID + amount int64 +} + +// Trace follows a row of the organization's ledger: a debit through the lots it took +// from, a credit through the lots it created, and each lot back through origin_lot_id +// to the lot that first brought the balance in. A row of another organization is +// repository.ErrWalletTransactionNotFound. +func (p *WalletTraceProcessor) Trace(ctx context.Context, organizationID, transactionID uuid.UUID) (*models.AdminWalletTrace, error) { + row, err := p.repo.GetTransaction(ctx, organizationID, transactionID) + if err != nil { + return nil, err + } + + var starts []walletTraceStart + if row.Amount < 0 { + allocations, err := p.repo.ListAllocations(ctx, row.ID) + if err != nil { + return nil, err + } + for _, a := range allocations { + starts = append(starts, walletTraceStart{lotID: a.LotID, amount: a.Amount}) + } + } else { + created, err := p.repo.ListLotsBySource(ctx, row.ID) + if err != nil { + return nil, err + } + for _, lot := range created { + starts = append(starts, walletTraceStart{lotID: lot.ID, amount: lot.OriginalAmount}) + } + } + + lots, err := p.loadChains(ctx, starts) + if err != nil { + return nil, err + } + + sourceIDs := make([]uuid.UUID, 0, len(lots)) + customerIDs := []uuid.UUID{row.CustomerID} + for _, lot := range lots { + sourceIDs = append(sourceIDs, lot.SourceTransactionID) + customerIDs = append(customerIDs, lot.CustomerID) + } + sourceRows, err := p.repo.GetTransactions(ctx, uniqueIDs(sourceIDs)) + if err != nil { + return nil, err + } + sources := make(map[uuid.UUID]entities.WalletTransaction, len(sourceRows)) + for _, s := range sourceRows { + sources[s.ID] = s + } + names, err := p.repo.CustomerNames(ctx, uniqueIDs(customerIDs)) + if err != nil { + return nil, err + } + + now := p.now() + trace := &models.AdminWalletTrace{ + Transaction: walletTraceRow(*row, names), + Lots: make([]models.AdminWalletTraceLot, 0, len(starts)), + } + for _, start := range starts { + item := models.AdminWalletTraceLot{Amount: start.amount, Chain: []models.AdminWalletTraceStep{}} + visited := map[uuid.UUID]bool{} + for id := &start.lotID; id != nil && !visited[*id]; { + lot, ok := lots[*id] + if !ok { + break + } + visited[*id] = true + item.Chain = append(item.Chain, models.AdminWalletTraceStep{ + Lot: adminWalletLot(lot, now), + Source: walletTraceRow(sources[lot.SourceTransactionID], names), + }) + id = lot.OriginLotID + } + trace.Lots = append(trace.Lots, item) + } + return trace, nil +} + +// loadChains loads the starting lots and every lot they were carried over from, one +// generation at a time. +func (p *WalletTraceProcessor) loadChains(ctx context.Context, starts []walletTraceStart) (map[uuid.UUID]entities.WalletLot, error) { + lots := map[uuid.UUID]entities.WalletLot{} + pending := make([]uuid.UUID, 0, len(starts)) + for _, s := range starts { + pending = append(pending, s.lotID) + } + for depth := 0; len(pending) > 0 && depth < walletTraceMaxDepth; depth++ { + found, err := p.repo.GetLots(ctx, uniqueIDs(pending)) + if err != nil { + return nil, err + } + pending = pending[:0] + for _, lot := range found { + lots[lot.ID] = lot + } + for _, lot := range found { + if lot.OriginLotID != nil { + if _, seen := lots[*lot.OriginLotID]; !seen { + pending = append(pending, *lot.OriginLotID) + } + } + } + } + return lots, nil +} + +func walletTraceRow(row entities.WalletTransaction, names map[uuid.UUID]string) models.AdminWalletTraceRow { + return models.AdminWalletTraceRow{ + ID: row.ID, + Customer: models.AdminWalletNamedRef{ID: row.CustomerID, Name: names[row.CustomerID]}, + Currency: row.Currency, + Type: row.Type, + Amount: row.Amount, + Description: row.Description, + ReferenceType: row.ReferenceType, + ReferenceID: row.ReferenceID, + OutletID: row.OutletID, + CreatedAt: row.CreatedAt, + } +} + +func adminWalletLot(lot entities.WalletLot, now time.Time) models.AdminWalletLot { + return models.AdminWalletLot{ + ID: lot.ID, + Currency: lot.Currency, + OriginalAmount: lot.OriginalAmount, + RemainingAmount: lot.RemainingAmount, + ExpiresAt: lot.ExpiresAt, + Expired: lot.ExpiresAt != nil && !lot.ExpiresAt.After(now), + SourceTransactionID: lot.SourceTransactionID, + OriginLotID: lot.OriginLotID, + CreatedAt: lot.CreatedAt, + } +} + +func uniqueIDs(ids []uuid.UUID) []uuid.UUID { + seen := make(map[uuid.UUID]bool, len(ids)) + out := make([]uuid.UUID, 0, len(ids)) + for _, id := range ids { + if !seen[id] { + seen[id] = true + out = append(out, id) + } + } + return out +} diff --git a/internal/processor/wallet_trace_processor_test.go b/internal/processor/wallet_trace_processor_test.go new file mode 100644 index 0000000..2a0aa01 --- /dev/null +++ b/internal/processor/wallet_trace_processor_test.go @@ -0,0 +1,161 @@ +package processor + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// walletTraceRepoFake reads the fake wallet the way the trace repository reads the +// tables. +type walletTraceRepoFake struct{ e *walletMoveEnv } + +func (f walletTraceRepoFake) GetTransaction(_ context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error) { + for _, tx := range f.e.repo.transactions { + if tx.ID == id && tx.OrganizationID == organizationID { + c := *tx + return &c, nil + } + } + return nil, repository.ErrWalletTransactionNotFound +} + +func (f walletTraceRepoFake) GetTransactions(_ context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error) { + var out []entities.WalletTransaction + for _, tx := range f.e.repo.transactions { + if containsID(ids, tx.ID) { + out = append(out, *tx) + } + } + return out, nil +} + +func (f walletTraceRepoFake) ListAllocations(ctx context.Context, id uuid.UUID) ([]entities.WalletLotAllocation, error) { + return f.e.repo.ListAllocationsByTransaction(ctx, id) +} + +func (f walletTraceRepoFake) ListLotsBySource(ctx context.Context, id uuid.UUID) ([]entities.WalletLot, error) { + return f.e.repo.ListLotsBySourceTransaction(ctx, id) +} + +func (f walletTraceRepoFake) GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) { + return f.e.repo.GetLotsByIDs(ctx, ids) +} + +func (f walletTraceRepoFake) CustomerNames(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) { + names := map[uuid.UUID]string{} + for _, id := range ids { + if c, ok := f.e.customers.byID[id]; ok { + names[id] = c.Name + } + } + return names, nil +} + +func containsID(ids []uuid.UUID, id uuid.UUID) bool { + for _, v := range ids { + if v == id { + return true + } + } + return false +} + +func findRow(t *testing.T, e *walletMoveEnv, customerID uuid.UUID, txType string) *entities.WalletTransaction { + t.Helper() + for _, tx := range e.repo.transactions { + if tx.CustomerID == customerID && tx.Type == txType { + return tx + } + } + t.Fatalf("no %s row for %s", txType, customerID) + return nil +} + +// The example of §8: A has 100 from #ORD-1 and 50 from #ORD-2, sends 120 to B, and B +// pays 30. Tracing B's payment leads to A's order #ORD-1. +func TestWalletTrace_PaymentLeadsBackToTheSendersOrder(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + b := e.member("Budi Santoso", "081234561234") + ord1 := earn(a, 100, e.at(30*24*time.Hour)) + ord1.Description = "Belanja #ORD-1" + ord2 := earn(a, 50, e.at(60*24*time.Hour)) + ord2.Description = "Belanja #ORD-2" + e.credit(t, ord1) + e.credit(t, ord2) + _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + payment, err := e.p.Debit(e.ctx, pay(b, 30)) + require.NoError(t, err) + + p := NewWalletTraceProcessor(walletTraceRepoFake{e}) + trace, err := p.Trace(e.ctx, e.org, payment.Transaction.ID) + require.NoError(t, err) + + assert.Equal(t, constants.WalletTxTypePayment, trace.Transaction.Type) + assert.Equal(t, "Budi Santoso", trace.Transaction.Customer.Name) + require.Len(t, trace.Lots, 1) + assert.Equal(t, int64(30), trace.Lots[0].Amount) + chain := trace.Lots[0].Chain + require.Len(t, chain, 2) + assert.Equal(t, constants.WalletTxTypeTransferIn, chain[0].Source.Type) + assert.Equal(t, b, chain[0].Source.Customer.ID) + assert.Equal(t, constants.WalletTxTypeEarn, chain[1].Source.Type) + assert.Equal(t, "Anita", chain[1].Source.Customer.Name) + assert.Equal(t, "Belanja #ORD-1", chain[1].Source.Description) + assert.Equal(t, constants.WalletRefTypeOrder, chain[1].Source.ReferenceType) + assert.Equal(t, ord1.ReferenceID, chain[1].Source.ReferenceID) + assert.Nil(t, chain[1].Lot.OriginLotID, "the chain ends at the lot the order created") + assert.Equal(t, *chain[0].Lot.OriginLotID, chain[1].Lot.ID) +} + +func TestWalletTrace_DebitAndCreditOfATransfer(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + b := e.member("Budi", "081234561234") + e.credit(t, earn(a, 100, e.at(time.Hour))) + e.credit(t, earn(a, 50, nil)) + _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + p := NewWalletTraceProcessor(walletTraceRepoFake{e}) + + // The sending side took from both of A's lots, which were A's own earnings. + out, err := p.Trace(e.ctx, e.org, findRow(t, e, a, constants.WalletTxTypeTransferOut).ID) + require.NoError(t, err) + require.Len(t, out.Lots, 2) + assert.Equal(t, []int64{100, 20}, []int64{out.Lots[0].Amount, out.Lots[1].Amount}) + for _, lot := range out.Lots { + require.Len(t, lot.Chain, 1) + assert.Equal(t, constants.WalletTxTypeEarn, lot.Chain[0].Source.Type) + } + + // The receiving side created two lots, each carried over from one of A's. + in, err := p.Trace(e.ctx, e.org, findRow(t, e, b, constants.WalletTxTypeTransferIn).ID) + require.NoError(t, err) + require.Len(t, in.Lots, 2) + for _, lot := range in.Lots { + require.Len(t, lot.Chain, 2) + assert.Equal(t, b, lot.Chain[0].Source.Customer.ID) + assert.Equal(t, a, lot.Chain[1].Source.Customer.ID) + assert.Equal(t, lot.Chain[1].Lot.ExpiresAt, lot.Chain[0].Lot.ExpiresAt) + } +} + +func TestWalletTrace_OtherOrganizationsRowsAreNotFound(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + res := e.credit(t, earn(a, 10, nil)) + + _, err := NewWalletTraceProcessor(walletTraceRepoFake{e}).Trace(e.ctx, uuid.New(), res.Transaction.ID) + assert.ErrorIs(t, err, repository.ErrWalletTransactionNotFound) +} diff --git a/internal/repository/wallet_trace_repository.go b/internal/repository/wallet_trace_repository.go new file mode 100644 index 0000000..aad7677 --- /dev/null +++ b/internal/repository/wallet_trace_repository.go @@ -0,0 +1,109 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" + + "apskel-pos-be/internal/entities" +) + +// ErrWalletTransactionNotFound means the ledger row does not exist or belongs to +// another organization. +var ErrWalletTransactionNotFound = errors.New("wallet: transaction not found") + +// WalletTraceRepository reads what tracing a ledger row lot by lot needs +// (docs/prd-point-coin.md F7, §8.1). Nothing here changes a balance. +type WalletTraceRepository interface { + // GetTransaction returns ErrWalletTransactionNotFound unless the row belongs to + // the organization. + GetTransaction(ctx context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error) + GetTransactions(ctx context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error) + // ListAllocations returns what a debit took from each lot, in the order it took. + ListAllocations(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error) + // ListLotsBySource returns the lots a credit created, oldest first. + ListLotsBySource(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error) + GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) + CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) +} + +type walletTraceRepository struct { + db *gorm.DB + names *walletAdminRepository +} + +func NewWalletTraceRepository(db *gorm.DB) WalletTraceRepository { + return &walletTraceRepository{db: db, names: &walletAdminRepository{db: db}} +} + +func (r *walletTraceRepository) GetTransaction(ctx context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error) { + var rows []entities.WalletTransaction + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("id = ? AND organization_id = ?", id, organizationID). + Limit(1). + Find(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to get wallet transaction: %w", err) + } + if len(rows) == 0 { + return nil, ErrWalletTransactionNotFound + } + return &rows[0], nil +} + +func (r *walletTraceRepository) GetTransactions(ctx context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error) { + var rows []entities.WalletTransaction + if len(ids) == 0 { + return rows, nil + } + if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id IN ?", ids).Find(&rows).Error; err != nil { + return nil, fmt.Errorf("failed to get wallet transactions: %w", err) + } + return rows, nil +} + +func (r *walletTraceRepository) ListAllocations(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error) { + var rows []entities.WalletLotAllocation + // A debit draws in K9 order, so the lots' order is the order it took from them. + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table("wallet_lot_allocations a"). + Select("a.*"). + Joins("JOIN wallet_lots l ON l.id = a.lot_id"). + Where("a.transaction_id = ?", transactionID). + Order("l.expires_at NULLS LAST, l.created_at, l.id"). + Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to list wallet lot allocations: %w", err) + } + return rows, nil +} + +func (r *walletTraceRepository) ListLotsBySource(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error) { + var lots []entities.WalletLot + err := DBFromContext(ctx, r.db).WithContext(ctx). + Where("source_transaction_id = ?", transactionID). + Order("created_at, id"). + Find(&lots).Error + if err != nil { + return nil, fmt.Errorf("failed to list wallet lots: %w", err) + } + return lots, nil +} + +func (r *walletTraceRepository) GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) { + var lots []entities.WalletLot + if len(ids) == 0 { + return lots, nil + } + if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id IN ?", ids).Find(&lots).Error; err != nil { + return nil, fmt.Errorf("failed to get wallet lots: %w", err) + } + return lots, nil +} + +func (r *walletTraceRepository) CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) { + return r.names.CustomerNames(ctx, ids) +} diff --git a/internal/router/router.go b/internal/router/router.go index 1bec687..365c95b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -653,6 +653,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { marketingCustomers.DELETE("/:id/pin", r.authMiddleware.RequireLoyaltyManager(), r.customerPinHandler.RemovePin) marketingCustomers.GET("/:id/security-events", r.customerPinHandler.ListSecurityEvents) } + // Trace one ledger row lot by lot back to where its balance came from (F7, §8.1) + gamification.GET("/wallet-transactions/:id/trace", r.walletAdminHandler.TraceTransaction) campaignRules := gamification.Group("/campaign-rules") { diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 5887bae..94fe041 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -30,6 +30,7 @@ func TestAllRoutesRegister(t *testing.T) { "GET /api/v1/customer/wallet/transactions", "GET /api/v1/marketing/customers/:id/wallet", "POST /api/v1/marketing/customers/:id/wallet/adjust", + "GET /api/v1/marketing/wallet-transactions/:id/trace", "GET /api/v1/outlets/:outlet_id/loyalty-settings", "PUT /api/v1/outlets/:outlet_id/loyalty-settings", "GET /api/v1/marketing/loyalty-settings", diff --git a/internal/service/wallet_admin_service.go b/internal/service/wallet_admin_service.go index 9ce934e..c29580d 100644 --- a/internal/service/wallet_admin_service.go +++ b/internal/service/wallet_admin_service.go @@ -19,14 +19,25 @@ import ( type WalletAdminService interface { GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response AdjustCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.AdjustCustomerWalletRequest) *contract.Response + // TraceTransaction follows a ledger row lot by lot back to where its balance came from. + TraceTransaction(ctx context.Context, apctx *appcontext.ContextInfo, transactionID uuid.UUID) *contract.Response } type WalletAdminServiceImpl struct { walletAdminProcessor *processor.WalletAdminProcessor + walletTraceProcessor *processor.WalletTraceProcessor } -func NewWalletAdminService(walletAdminProcessor *processor.WalletAdminProcessor) *WalletAdminServiceImpl { - return &WalletAdminServiceImpl{walletAdminProcessor: walletAdminProcessor} +func NewWalletAdminService(walletAdminProcessor *processor.WalletAdminProcessor, walletTraceProcessor *processor.WalletTraceProcessor) *WalletAdminServiceImpl { + return &WalletAdminServiceImpl{walletAdminProcessor: walletAdminProcessor, walletTraceProcessor: walletTraceProcessor} +} + +func (s *WalletAdminServiceImpl) TraceTransaction(ctx context.Context, apctx *appcontext.ContextInfo, transactionID uuid.UUID) *contract.Response { + trace, err := s.walletTraceProcessor.Trace(ctx, apctx.OrganizationID, transactionID) + if err != nil { + return walletErrorResponse(err) + } + return contract.BuildSuccessResponse(trace) } func (s *WalletAdminServiceImpl) GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response { @@ -56,6 +67,8 @@ func walletErrorResponse(err error) *contract.Response { switch { case errors.Is(err, repository.ErrWalletNotFound): code, message = constants.NotFoundErrorCode, "customer not found" + case errors.Is(err, repository.ErrWalletTransactionNotFound): + code, message = constants.NotFoundErrorCode, "wallet transaction not found" case errors.Is(err, repository.ErrWalletInsufficientBalance): code, message = constants.ValidationErrorCode, "the adjustment would take more than the customer can spend" case errors.Is(err, processor.ErrInvalidWalletQuery), -- 2.54.0 From bf9651e221afda67140da1a1a918a00e70ec1578 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 12:30:14 +0700 Subject: [PATCH 38/47] feat(loyalty): notify transfer recipients through FCM The recipient of a transfer now gets a push through FCM instead of a WhatsApp message (docs/prd-point-coin.md F5). Customers had nowhere to keep FCM tokens: user_devices only holds staff devices. Migration 000096 adds customer_devices, and the customer app registers with PUT /customer/devices { device_id, fcm_token, platform, app_version } after login and whenever FCM refreshes the token, and unregisters with DELETE /customer/devices/:device_id on logout. A token belongs to one customer only: registering it takes it away from whoever had it on that phone before, so they stop getting this customer's notifications. The push goes to every device of the recipient after the commit, titled "EnakPoint masuk" or "EnakCoin masuk", with type WALLET_TRANSFER_IN, the TRANSFER_IN transaction id, the group id, the currency and the amount in its data so the app can open it. A retried transfer sends nothing again. It stays best effort: no device, FCM not configured or FCM failing is logged and never undoes the transfer. The app builds one FCM client and shares it between staff notifications and customer pushes. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 40 ++++-- internal/contract/customer_device_contract.go | 11 ++ internal/handler/customer_device_handler.go | 45 ++++++ .../processor/customer_device_processor.go | 77 ++++++++++ .../customer_device_processor_test.go | 136 ++++++++++++++++++ .../wallet_exchange_processor_test.go | 4 +- .../processor/wallet_transfer_processor.go | 47 +++--- .../wallet_transfer_processor_test.go | 44 ++++-- .../repository/customer_device_repository.go | 84 +++++++++++ internal/router/router.go | 6 +- internal/router/router_test.go | 2 + internal/service/customer_device_service.go | 58 ++++++++ .../000096_create_customer_devices.down.sql | 1 + .../000096_create_customer_devices.up.sql | 17 +++ 14 files changed, 526 insertions(+), 46 deletions(-) create mode 100644 internal/contract/customer_device_contract.go create mode 100644 internal/handler/customer_device_handler.go create mode 100644 internal/processor/customer_device_processor.go create mode 100644 internal/processor/customer_device_processor_test.go create mode 100644 internal/repository/customer_device_repository.go create mode 100644 internal/service/customer_device_service.go create mode 100644 migrations/000096_create_customer_devices.down.sql create mode 100644 migrations/000096_create_customer_devices.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index 63437f9..1ac98bb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -160,6 +160,7 @@ func (a *App) Initialize(cfg *config.Config) error { services.pointPaymentService, services.customerOrderPaymentService, services.customerWalletService, + services.customerDeviceService, a.redisClient, ) @@ -396,10 +397,14 @@ type processors struct { walletExchangeProcessor *processor.WalletExchangeProcessor walletTransferProcessor *processor.WalletTransferProcessor walletTraceProcessor *processor.WalletTraceProcessor + customerDeviceProcessor *processor.CustomerDeviceProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { fileClient := client.NewFileClient(cfg.S3Config) + fcmClient := buildFCMClient(cfg) + // Devices of the customer app, for pushes through FCM + customerDeviceProcessor := processor.NewCustomerDeviceProcessor(repository.NewCustomerDeviceRepository(a.db), fcmClient) fonnteClient := client.NewFonnteClient(cfg.GetFonnte()) otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo) // Customer PIN (docs/prd-point-coin.md F11) @@ -417,8 +422,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor) // Exchange EnakCoin into EnakPoint, approved by the customer's PIN (docs/prd-point-coin.md F4) walletExchangeProcessor := processor.NewWalletExchangeProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) - // Send EnakPoint or EnakCoin to another customer; the recipient is told by WhatsApp (docs/prd-point-coin.md F5) - walletTransferProcessor := processor.NewWalletTransferProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, otpProcessor) + // Send EnakPoint or EnakCoin to another customer; the recipient gets a push through FCM (docs/prd-point-coin.md F5) + walletTransferProcessor := processor.NewWalletTransferProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, customerDeviceProcessor) return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), @@ -461,7 +466,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor fileClient: fileClient, inventoryMovementService: inventoryMovementService, userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo), - notificationProcessor: buildNotificationProcessor(cfg, repos), + notificationProcessor: buildNotificationProcessor(repos, fcmClient), productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo), expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo), cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo), @@ -474,6 +479,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor walletExchangeProcessor: walletExchangeProcessor, walletTransferProcessor: walletTransferProcessor, walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)), + customerDeviceProcessor: customerDeviceProcessor, walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), } } @@ -523,6 +529,7 @@ type services struct { pointPaymentService *service.PointPaymentServiceImpl customerOrderPaymentService *service.CustomerOrderPaymentServiceImpl customerWalletService *service.CustomerWalletServiceImpl + customerDeviceService *service.CustomerDeviceServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -610,6 +617,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor), customerOrderPaymentService: service.NewCustomerOrderPaymentService(processors.orderProcessor), customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor, processors.walletTransferProcessor), + customerDeviceService: service.NewCustomerDeviceService(processors.customerDeviceProcessor), } } @@ -693,19 +701,23 @@ func (a *App) initValidators() *validators { } } +// buildFCMClient connects to FCM, or returns nil when it is not configured or fails to +// start. FCM failing is non-fatal: pushes are skipped and notifications are still +// persisted. +func buildFCMClient(cfg *config.Config) client.FCMClient { + if cfg.FCM.CredentialsFile == "" { + return nil + } + fcmClient, err := client.NewFCMClient(&cfg.FCM) + if err != nil { + return nil + } + return fcmClient +} + // buildNotificationProcessor creates the notification processor with FCM integration. // If FCM is not configured, it returns a processor with a nil FCM client (FCM dispatch will be skipped). -func buildNotificationProcessor(cfg *config.Config, repos *repositories) *processor.NotificationProcessorImpl { - var fcmClient client.FCMClient - if cfg.FCM.CredentialsFile != "" { - var err error - fcmClient, err = client.NewFCMClient(&cfg.FCM) - if err != nil { - // FCM init failure is non-fatal; notifications will still be persisted. - fcmClient = nil - } - } - +func buildNotificationProcessor(repos *repositories, fcmClient client.FCMClient) *processor.NotificationProcessorImpl { return processor.NewNotificationProcessor( repos.notificationRepo, repos.notificationReceiverRepo, diff --git a/internal/contract/customer_device_contract.go b/internal/contract/customer_device_contract.go new file mode 100644 index 0000000..f304af0 --- /dev/null +++ b/internal/contract/customer_device_contract.go @@ -0,0 +1,11 @@ +package contract + +// RegisterCustomerDeviceRequest is PUT /customer/devices: the FCM token the customer +// app got for this device. Send it after login and whenever FCM refreshes the token. +type RegisterCustomerDeviceRequest struct { + DeviceID string `json:"device_id" binding:"required"` + FCMToken string `json:"fcm_token" binding:"required"` + // android, ios or web. + Platform *string `json:"platform"` + AppVersion *string `json:"app_version"` +} diff --git a/internal/handler/customer_device_handler.go b/internal/handler/customer_device_handler.go new file mode 100644 index 0000000..da03f32 --- /dev/null +++ b/internal/handler/customer_device_handler.go @@ -0,0 +1,45 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" +) + +// CustomerDeviceHandler serves /customer/devices, where the customer app registers +// the FCM token of each device it runs on. +type CustomerDeviceHandler struct { + devices service.CustomerDeviceService +} + +func NewCustomerDeviceHandler(devices service.CustomerDeviceService) *CustomerDeviceHandler { + return &CustomerDeviceHandler{devices: devices} +} + +// Register is PUT /customer/devices. +func (h *CustomerDeviceHandler) Register(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerDeviceHandler::Register") + if !ok { + return + } + var req contract.RegisterCustomerDeviceRequest + if err := c.ShouldBindJSON(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "device_id and fcm_token are required"), + }), "CustomerDeviceHandler::Register") + return + } + util.HandleResponse(c.Writer, c.Request, h.devices.Register(c.Request.Context(), customerID, &req), "CustomerDeviceHandler::Register") +} + +// Unregister is DELETE /customer/devices/:device_id, for logout. +func (h *CustomerDeviceHandler) Unregister(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerDeviceHandler::Unregister") + if !ok { + return + } + util.HandleResponse(c.Writer, c.Request, h.devices.Unregister(c.Request.Context(), customerID, c.Param("device_id")), "CustomerDeviceHandler::Unregister") +} diff --git a/internal/processor/customer_device_processor.go b/internal/processor/customer_device_processor.go new file mode 100644 index 0000000..419c2f4 --- /dev/null +++ b/internal/processor/customer_device_processor.go @@ -0,0 +1,77 @@ +package processor + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/repository" +) + +// ErrInvalidCustomerDevice wraps every rejection of a device registration. +var ErrInvalidCustomerDevice = errors.New("invalid customer device") + +type customerPushSender interface { + SendMulticastNotification(ctx context.Context, tokens []string, title string, body string, data map[string]string) error +} + +// CustomerDeviceProcessor keeps the customer app's FCM tokens and sends push +// notifications to a customer's devices. +type CustomerDeviceProcessor struct { + repo repository.CustomerDeviceRepository + // Nil when FCM is not configured; notifications are then skipped. + fcm customerPushSender + now func() time.Time +} + +func NewCustomerDeviceProcessor(repo repository.CustomerDeviceRepository, fcm customerPushSender) *CustomerDeviceProcessor { + return &CustomerDeviceProcessor{repo: repo, fcm: fcm, now: time.Now} +} + +// Register saves the FCM token the app got for this device. The app calls it after +// login and whenever FCM gives it a new token. +func (p *CustomerDeviceProcessor) Register(ctx context.Context, device repository.CustomerDevice) error { + device.DeviceID = strings.TrimSpace(device.DeviceID) + device.FCMToken = strings.TrimSpace(device.FCMToken) + switch { + case device.DeviceID == "" || len(device.DeviceID) > 255: + return fmt.Errorf("%w: device_id is required, at most 255 characters", ErrInvalidCustomerDevice) + case device.FCMToken == "" || len(device.FCMToken) > 512: + return fmt.Errorf("%w: fcm_token is required, at most 512 characters", ErrInvalidCustomerDevice) + } + if device.Platform != nil { + platform := strings.ToLower(strings.TrimSpace(*device.Platform)) + if platform != "android" && platform != "ios" && platform != "web" { + return fmt.Errorf("%w: platform must be android, ios or web", ErrInvalidCustomerDevice) + } + device.Platform = &platform + } + return p.repo.Register(ctx, device, p.now()) +} + +// Unregister forgets a device, so it stops getting the customer's notifications. +func (p *CustomerDeviceProcessor) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error { + return p.repo.Unregister(ctx, customerID, strings.TrimSpace(deviceID)) +} + +// Notify pushes a notification to every device of the customer through FCM. A +// customer without a registered device gets nothing, which is not an error. +func (p *CustomerDeviceProcessor) Notify(ctx context.Context, customerID uuid.UUID, title, body string, data map[string]string) error { + if p.fcm == nil { + logger.NonContext.Info(fmt.Sprintf("FCM is not configured; not notifying customer %s", customerID)) + return nil + } + tokens, err := p.repo.ListTokens(ctx, customerID) + if err != nil { + return err + } + if len(tokens) == 0 { + return nil + } + return p.fcm.SendMulticastNotification(ctx, tokens, title, body, data) +} diff --git a/internal/processor/customer_device_processor_test.go b/internal/processor/customer_device_processor_test.go new file mode 100644 index 0000000..d8c3156 --- /dev/null +++ b/internal/processor/customer_device_processor_test.go @@ -0,0 +1,136 @@ +package processor + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/repository" +) + +// customerDeviceRepoFake keeps devices the way the table does: one row per customer +// and device, and a token belongs to one row only. +type customerDeviceRepoFake struct{ devices []repository.CustomerDevice } + +func (f *customerDeviceRepoFake) Register(_ context.Context, d repository.CustomerDevice, _ time.Time) error { + kept := f.devices[:0] + for _, existing := range f.devices { + sameRow := existing.CustomerID == d.CustomerID && existing.DeviceID == d.DeviceID + if !sameRow && existing.FCMToken != d.FCMToken { + kept = append(kept, existing) + } + } + f.devices = append(kept, d) + return nil +} + +func (f *customerDeviceRepoFake) Unregister(_ context.Context, customerID uuid.UUID, deviceID string) error { + kept := f.devices[:0] + for _, d := range f.devices { + if d.CustomerID != customerID || d.DeviceID != deviceID { + kept = append(kept, d) + } + } + f.devices = kept + return nil +} + +func (f *customerDeviceRepoFake) ListTokens(_ context.Context, customerID uuid.UUID) ([]string, error) { + var tokens []string + for _, d := range f.devices { + if d.CustomerID == customerID { + tokens = append(tokens, d.FCMToken) + } + } + return tokens, nil +} + +type fcmFake struct { + tokens [][]string + title string + body string + data map[string]string + err error +} + +func (f *fcmFake) SendMulticastNotification(_ context.Context, tokens []string, title, body string, data map[string]string) error { + f.tokens = append(f.tokens, tokens) + f.title, f.body, f.data = title, body, data + return f.err +} + +func TestCustomerDevice_NotifiesEveryDeviceOfTheCustomer(t *testing.T) { + repo, fcm := &customerDeviceRepoFake{}, &fcmFake{} + p := NewCustomerDeviceProcessor(repo, fcm) + ctx := context.Background() + budi, anita := uuid.New(), uuid.New() + + require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "t1", Platform: ptr("Android")})) + require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "tablet", FCMToken: "t2"})) + // A refreshed token replaces the old one of the same device. + require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "t1b"})) + require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: anita, DeviceID: "phone", FCMToken: "t3"})) + + require.NoError(t, p.Notify(ctx, budi, "EnakPoint masuk", "Kamu menerima 10 EnakPoint", map[string]string{"type": "X"})) + assert.Equal(t, [][]string{{"t2", "t1b"}}, fcm.tokens) + assert.Equal(t, "EnakPoint masuk", fcm.title) + assert.Equal(t, map[string]string{"type": "X"}, fcm.data) +} + +func TestCustomerDevice_TokenMovesToWhoeverLogsInOnThePhone(t *testing.T) { + repo, fcm := &customerDeviceRepoFake{}, &fcmFake{} + p := NewCustomerDeviceProcessor(repo, fcm) + ctx := context.Background() + budi, anita := uuid.New(), uuid.New() + + require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "shared"})) + require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: anita, DeviceID: "phone", FCMToken: "shared"})) + + // Budi's notifications no longer reach the phone Anita is now logged in on. + require.NoError(t, p.Notify(ctx, budi, "t", "b", nil)) + require.NoError(t, p.Notify(ctx, anita, "t", "b", nil)) + assert.Equal(t, [][]string{{"shared"}}, fcm.tokens) +} + +func TestCustomerDevice_NothingToSend(t *testing.T) { + repo, fcm := &customerDeviceRepoFake{}, &fcmFake{} + ctx := context.Background() + customer := uuid.New() + + // No device registered, or logged out: nothing is sent and nothing fails. + require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Notify(ctx, customer, "t", "b", nil)) + require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Register(ctx, repository.CustomerDevice{CustomerID: customer, DeviceID: "phone", FCMToken: "t1"})) + require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Unregister(ctx, customer, "phone")) + require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Notify(ctx, customer, "t", "b", nil)) + assert.Empty(t, fcm.tokens) + + // FCM not configured. + require.NoError(t, NewCustomerDeviceProcessor(repo, nil).Notify(ctx, customer, "t", "b", nil)) +} + +func TestCustomerDevice_FCMFailureIsReported(t *testing.T) { + repo, fcm := &customerDeviceRepoFake{}, &fcmFake{err: errors.New("unavailable")} + p := NewCustomerDeviceProcessor(repo, fcm) + ctx := context.Background() + customer := uuid.New() + require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: customer, DeviceID: "phone", FCMToken: "t1"})) + + assert.Error(t, p.Notify(ctx, customer, "t", "b", nil)) +} + +func TestCustomerDevice_RejectsIncompleteRegistrations(t *testing.T) { + p := NewCustomerDeviceProcessor(&customerDeviceRepoFake{}, nil) + ctx := context.Background() + for name, d := range map[string]repository.CustomerDevice{ + "no device": {FCMToken: "t"}, + "no token": {DeviceID: "phone", FCMToken: " "}, + "bad platform": {DeviceID: "phone", FCMToken: "t", Platform: ptr("symbian")}, + } { + assert.ErrorIs(t, p.Register(ctx, d), ErrInvalidCustomerDevice, name) + } +} diff --git a/internal/processor/wallet_exchange_processor_test.go b/internal/processor/wallet_exchange_processor_test.go index 873dbd1..83672f1 100644 --- a/internal/processor/wallet_exchange_processor_test.go +++ b/internal/processor/wallet_exchange_processor_test.go @@ -57,8 +57,8 @@ func (e *walletMoveEnv) exchanges() *WalletExchangeProcessor { return p } -func (e *walletMoveEnv) transfers(messenger walletMessenger) *WalletTransferProcessor { - p := NewWalletTransferProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}, messenger) +func (e *walletMoveEnv) transfers(notifier customerNotifier) *WalletTransferProcessor { + p := NewWalletTransferProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}, notifier) p.now = func() time.Time { return e.now } return p } diff --git a/internal/processor/wallet_transfer_processor.go b/internal/processor/wallet_transfer_processor.go index b13c846..6c2d7c1 100644 --- a/internal/processor/wallet_transfer_processor.go +++ b/internal/processor/wallet_transfer_processor.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "strings" "time" "unicode/utf8" @@ -21,12 +22,16 @@ import ( // check does not reveal who uses the app elsewhere. var ErrWalletRecipientNotFound = errors.New("no customer of this organization has that phone number") -// walletMessenger tells a customer something happened to their wallet. There is no -// push channel to customers yet, so the app sends it by WhatsApp. -type walletMessenger interface { - SendWhatsAppMessage(phoneNumber, message string) error +// customerNotifier pushes a notification to a customer's app through FCM. +// CustomerDeviceProcessor is one. +type customerNotifier interface { + Notify(ctx context.Context, customerID uuid.UUID, title, body string, data map[string]string) error } +// NotificationTypeWalletTransferIn is the data type of the push a transfer recipient +// gets, so the app can open the transaction. +const NotificationTypeWalletTransferIn = "WALLET_TRANSFER_IN" + // WalletTransferProcessor sends EnakPoint or EnakCoin from one customer to another in // the same organization (docs/prd-point-coin.md F5). type WalletTransferProcessor struct { @@ -36,12 +41,12 @@ type WalletTransferProcessor struct { pins pinVerifier wallet *WalletProcessor tx TxRunner - messenger walletMessenger + notifier customerNotifier now func() time.Time } -func NewWalletTransferProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner, messenger walletMessenger) *WalletTransferProcessor { - return &WalletTransferProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, messenger: messenger, now: time.Now} +func NewWalletTransferProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletTransferProcessor { + return &WalletTransferProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, notifier: notifier, now: time.Now} } // Recipient is GET /customer/wallet/transfer/recipient: the masked name and number @@ -118,6 +123,7 @@ func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UU outKey := fmt.Sprintf("transfer:%s:%s:out", senderID, key) inKey := fmt.Sprintf("transfer:%s:%s:in", senderID, key) result := &models.WalletTransferResult{Currency: currency, Amount: in.Amount, Recipient: *to} + var receivedID uuid.UUID err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { if err := p.wallet.LockWallets(ctx, senderID, recipient.ID); err != nil { return err @@ -185,6 +191,7 @@ func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UU result.GroupID = groupID result.Lots = movedLots(received.Lots) result.Replayed = out.Replayed + receivedID = received.Transaction.ID return nil }) if err != nil { @@ -192,7 +199,7 @@ func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UU } if !result.Replayed { - p.tellRecipient(recipient, from, currency, in.Amount) + p.tellRecipient(ctx, recipient.ID, from, currency, in.Amount, receivedID, result.GroupID) } balances, err := p.spendable.SpendableBalances(ctx, senderID, p.now()) if err != nil { @@ -228,16 +235,24 @@ func (p *WalletTransferProcessor) recipient(ctx context.Context, sender *reposit return recipient, nil } -// tellRecipient is best effort: the transfer has already happened, so a failure to -// send the message is only logged. -func (p *WalletTransferProcessor) tellRecipient(recipient *repository.WalletMoveCustomer, sender *models.WalletTransferRecipient, currency string, amount int64) { - if p.messenger == nil || recipient.PhoneNumber == nil { +// tellRecipient pushes the transfer to the recipient's app (F5). It is best effort: +// the transfer has already happened, so a failure to send is only logged. +func (p *WalletTransferProcessor) tellRecipient(ctx context.Context, recipientID uuid.UUID, sender *models.WalletTransferRecipient, currency string, amount int64, transactionID, groupID uuid.UUID) { + if p.notifier == nil { return } - message := fmt.Sprintf("Kamu menerima %d %s dari %s (%s). Cek riwayatnya di aplikasi.", - amount, walletCurrencyName(currency), sender.Name, sender.PhoneNumber) - if err := p.messenger.SendWhatsAppMessage(*recipient.PhoneNumber, message); err != nil { - logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about a transfer", recipient.ID), err) + name := walletCurrencyName(currency) + title := name + " masuk" + body := fmt.Sprintf("Kamu menerima %d %s dari %s (%s).", amount, name, sender.Name, sender.PhoneNumber) + data := map[string]string{ + "type": NotificationTypeWalletTransferIn, + "transaction_id": transactionID.String(), + "group_id": groupID.String(), + "currency": currency, + "amount": strconv.FormatInt(amount, 10), + } + if err := p.notifier.Notify(ctx, recipientID, title, body, data); err != nil { + logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about a transfer", recipientID), err) } } diff --git a/internal/processor/wallet_transfer_processor_test.go b/internal/processor/wallet_transfer_processor_test.go index 1aa14a9..090ca88 100644 --- a/internal/processor/wallet_transfer_processor_test.go +++ b/internal/processor/wallet_transfer_processor_test.go @@ -1,6 +1,7 @@ package processor import ( + "context" "errors" "testing" "time" @@ -14,13 +15,19 @@ import ( "apskel-pos-be/internal/repository" ) -type messengerFake struct{ sent map[string][]string } +type pushFake struct { + title, body string + data map[string]string +} -func (f *messengerFake) SendWhatsAppMessage(phone, message string) error { - if f.sent == nil { - f.sent = map[string][]string{} +// notifierFake records the pushes each customer would get. +type notifierFake struct{ pushes map[uuid.UUID][]pushFake } + +func (f *notifierFake) Notify(_ context.Context, customerID uuid.UUID, title, body string, data map[string]string) error { + if f.pushes == nil { + f.pushes = map[uuid.UUID][]pushFake{} } - f.sent[phone] = append(f.sent[phone], message) + f.pushes[customerID] = append(f.pushes[customerID], pushFake{title: title, body: body, data: data}) return nil } @@ -35,10 +42,10 @@ func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) { dec, jan := e.at(30*24*time.Hour), e.at(60*24*time.Hour) first := e.credit(t, earn(a, 100, dec)) second := e.credit(t, earn(a, 50, jan)) - messenger := &messengerFake{} + notifier := ¬ifierFake{} // The example in §8: A sends 120, 100 from the lot expiring first and 20 from the next. - res, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) + res, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) require.NoError(t, err) assert.Equal(t, int64(30), res.Balance) @@ -74,7 +81,18 @@ func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) { assert.GreaterOrEqual(t, e.repo.locks[a], 1) assert.GreaterOrEqual(t, e.repo.locks[b], 1) - assert.Equal(t, []string{"Kamu menerima 120 EnakPoint dari An*** (08**-****-5678). Cek riwayatnya di aplikasi."}, messenger.sent["081234561234"]) + assert.Equal(t, []pushFake{{ + title: "EnakPoint masuk", + body: "Kamu menerima 120 EnakPoint dari An*** (08**-****-5678).", + data: map[string]string{ + "type": NotificationTypeWalletTransferIn, + "transaction_id": in.ID.String(), + "group_id": in.GroupID.String(), + "currency": constants.WalletCurrencyPoint, + "amount": "120", + }, + }}, notifier.pushes[b]) + assert.Empty(t, notifier.pushes[a], "the sender gets no push") } func TestWalletTransfer_Coins(t *testing.T) { @@ -191,20 +209,20 @@ func TestWalletTransfer_RetryMovesNothingAndTellsNobodyAgain(t *testing.T) { b := e.member("Budi", "081234561234") e.member("Citra", "081255550000") e.credit(t, earn(a, 100, nil)) - messenger := &messengerFake{} + notifier := ¬ifierFake{} - first, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) + first, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) require.NoError(t, err) - again, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) + again, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) require.NoError(t, err) assert.True(t, again.Replayed) assert.Equal(t, first.GroupID, again.GroupID) assert.Equal(t, int64(40), e.balance(t, b)) - assert.Len(t, messenger.sent["081234561234"], 1) + assert.Len(t, notifier.pushes[b], 1) // The same key to someone else is not a retry. - _, err = e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081255550000"), "482913", "key-1", models.CustomerPinRequestInfo{}) + _, err = e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081255550000"), "482913", "key-1", models.CustomerPinRequestInfo{}) assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) } diff --git a/internal/repository/customer_device_repository.go b/internal/repository/customer_device_repository.go new file mode 100644 index 0000000..5c38807 --- /dev/null +++ b/internal/repository/customer_device_repository.go @@ -0,0 +1,84 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// CustomerDevice is one install of the customer app that can receive push +// notifications. +type CustomerDevice struct { + CustomerID uuid.UUID + DeviceID string + Platform *string + FCMToken string + AppVersion *string +} + +// CustomerDeviceRepository keeps the FCM tokens of the customer app. +type CustomerDeviceRepository interface { + // Register saves the device's current token. A token is only ever one customer's: + // registering it takes it away from whoever had it before on that phone, so they + // stop getting this customer's notifications. + Register(ctx context.Context, device CustomerDevice, now time.Time) error + // Unregister forgets a device, for instance on logout. An unknown device is not an error. + Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error + ListTokens(ctx context.Context, customerID uuid.UUID) ([]string, error) +} + +type customerDeviceRepository struct { + db *gorm.DB +} + +func NewCustomerDeviceRepository(db *gorm.DB) CustomerDeviceRepository { + return &customerDeviceRepository{db: db} +} + +func (r *customerDeviceRepository) Register(ctx context.Context, d CustomerDevice, now time.Time) error { + return DBFromContext(ctx, r.db).WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := tx.Exec(`DELETE FROM customer_devices WHERE fcm_token = ? AND NOT (customer_id = ? AND device_id = ?)`, + d.FCMToken, d.CustomerID, d.DeviceID).Error + if err != nil { + return fmt.Errorf("failed to release FCM token: %w", err) + } + err = tx.Exec(`INSERT INTO customer_devices (customer_id, device_id, platform, fcm_token, app_version, last_active_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (customer_id, device_id) DO UPDATE SET + platform = EXCLUDED.platform, + fcm_token = EXCLUDED.fcm_token, + app_version = EXCLUDED.app_version, + last_active_at = EXCLUDED.last_active_at, + updated_at = NOW()`, + d.CustomerID, d.DeviceID, d.Platform, d.FCMToken, d.AppVersion, now).Error + if err != nil { + return fmt.Errorf("failed to register customer device: %w", err) + } + return nil + }) +} + +func (r *customerDeviceRepository) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error { + err := DBFromContext(ctx, r.db).WithContext(ctx). + Exec(`DELETE FROM customer_devices WHERE customer_id = ? AND device_id = ?`, customerID, deviceID).Error + if err != nil { + return fmt.Errorf("failed to unregister customer device: %w", err) + } + return nil +} + +func (r *customerDeviceRepository) ListTokens(ctx context.Context, customerID uuid.UUID) ([]string, error) { + var tokens []string + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table("customer_devices"). + Where("customer_id = ? AND fcm_token <> ''", customerID). + Order("last_active_at DESC NULLS LAST"). + Pluck("fcm_token", &tokens).Error + if err != nil { + return nil, fmt.Errorf("failed to list customer FCM tokens: %w", err) + } + return tokens, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index 365c95b..d5770b0 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -60,12 +60,13 @@ type Router struct { pointPaymentHandler *handler.PointPaymentHandler customerOrderPaymentHandler *handler.CustomerOrderPaymentHandler customerWalletHandler *handler.CustomerWalletHandler + customerDeviceHandler *handler.CustomerDeviceHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, customerWalletService service.CustomerWalletService, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -117,6 +118,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer pointPaymentHandler: handler.NewPointPaymentHandler(pointPaymentService), customerOrderPaymentHandler: handler.NewCustomerOrderPaymentHandler(customerOrderPaymentService), customerWalletHandler: handler.NewCustomerWalletHandler(customerWalletService), + customerDeviceHandler: handler.NewCustomerDeviceHandler(customerDeviceService), redisClient: redisClient, } } @@ -175,6 +177,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.POST("/wallet/exchange", r.customerWalletHandler.Exchange) customer.GET("/wallet/transfer/recipient", r.customerWalletHandler.TransferRecipient) customer.POST("/wallet/transfer", r.customerWalletHandler.Transfer) + customer.PUT("/devices", r.customerDeviceHandler.Register) + customer.DELETE("/devices/:device_id", r.customerDeviceHandler.Unregister) customer.POST("/orders/:id/pay-with-points", r.customerOrderPaymentHandler.PayWithPoints) // PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11) customer.GET("/pin/status", r.customerPinHandler.Status) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 94fe041..0d377c9 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -41,6 +41,8 @@ func TestAllRoutesRegister(t *testing.T) { "POST /api/v1/customer/wallet/exchange", "GET /api/v1/customer/wallet/transfer/recipient", "POST /api/v1/customer/wallet/transfer", + "PUT /api/v1/customer/devices", + "DELETE /api/v1/customer/devices/:device_id", "GET /api/v1/orders/:id/point-payment/preview", "POST /api/v1/customer/orders/:id/pay-with-points", "GET /api/v1/customer/pin/status", diff --git a/internal/service/customer_device_service.go b/internal/service/customer_device_service.go new file mode 100644 index 0000000..157d93b --- /dev/null +++ b/internal/service/customer_device_service.go @@ -0,0 +1,58 @@ +package service + +import ( + "context" + "errors" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" +) + +// CustomerDeviceService registers the customer app's devices for push notifications. +type CustomerDeviceService interface { + Register(ctx context.Context, customerID uuid.UUID, req *contract.RegisterCustomerDeviceRequest) *contract.Response + Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) *contract.Response +} + +type CustomerDeviceServiceImpl struct { + devices *processor.CustomerDeviceProcessor +} + +func NewCustomerDeviceService(devices *processor.CustomerDeviceProcessor) *CustomerDeviceServiceImpl { + return &CustomerDeviceServiceImpl{devices: devices} +} + +func (s *CustomerDeviceServiceImpl) Register(ctx context.Context, customerID uuid.UUID, req *contract.RegisterCustomerDeviceRequest) *contract.Response { + err := s.devices.Register(ctx, repository.CustomerDevice{ + CustomerID: customerID, + DeviceID: req.DeviceID, + FCMToken: req.FCMToken, + Platform: req.Platform, + AppVersion: req.AppVersion, + }) + if err != nil { + return customerDeviceErrorResponse(err) + } + return contract.BuildSuccessResponse(map[string]interface{}{"device_id": req.DeviceID}) +} + +func (s *CustomerDeviceServiceImpl) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) *contract.Response { + if err := s.devices.Unregister(ctx, customerID, deviceID); err != nil { + return customerDeviceErrorResponse(err) + } + return contract.BuildSuccessResponse(map[string]interface{}{"device_id": deviceID}) +} + +func customerDeviceErrorResponse(err error) *contract.Response { + code := constants.InternalServerErrorCode + if errors.Is(err, processor.ErrInvalidCustomerDevice) { + code = constants.ValidationErrorCode + } + return contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(code, constants.RequestEntity, err.Error()), + }) +} diff --git a/migrations/000096_create_customer_devices.down.sql b/migrations/000096_create_customer_devices.down.sql new file mode 100644 index 0000000..b541b49 --- /dev/null +++ b/migrations/000096_create_customer_devices.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS customer_devices; diff --git a/migrations/000096_create_customer_devices.up.sql b/migrations/000096_create_customer_devices.up.sql new file mode 100644 index 0000000..c8f9d0d --- /dev/null +++ b/migrations/000096_create_customer_devices.up.sql @@ -0,0 +1,17 @@ +-- Devices of the customer app, so customers can get push notifications through FCM +-- (docs/prd-point-coin.md F5: the recipient of a transfer is notified). user_devices +-- only holds staff devices. +CREATE TABLE customer_devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + device_id VARCHAR(255) NOT NULL, + platform VARCHAR(50) CHECK (platform IN ('android', 'ios', 'web')), + fcm_token VARCHAR(512) NOT NULL, + app_version VARCHAR(50), + last_active_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_customer_devices_customer_device ON customer_devices(customer_id, device_id); +CREATE INDEX idx_customer_devices_fcm_token ON customer_devices(fcm_token); -- 2.54.0 From 4432f0a10d48a967bb7a2993a300b7d913784fb3 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 12:33:20 +0700 Subject: [PATCH 39/47] feat(loyalty): push a locked PIN through FCM A customer whose PIN locks after five wrong attempts is now told by a push through FCM instead of WhatsApp (docs/prd-point-coin.md F11), to every device registered at /customer/devices. The push is titled "PIN terkunci", says until when it is locked, and carries type PIN_LOCKED and locked_until in its data so the app can offer the PIN reset. As before, only the attempt that reached the limit sends it, and a failure to send is logged without affecting the lock. OtpProcessor.SendWhatsAppMessage was only there for this alert and is removed; OTPs still go out by WhatsApp. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 2 +- internal/processor/customer_pin_processor.go | 38 ++++---- .../customer_pin_processor_db_test.go | 4 +- .../processor/customer_pin_processor_test.go | 95 ++++++++++++++++--- internal/processor/otp_processor.go | 9 -- 5 files changed, 107 insertions(+), 41 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 1ac98bb..91862c4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -408,7 +408,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor fonnteClient := client.NewFonnteClient(cfg.GetFonnte()) otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo) // Customer PIN (docs/prd-point-coin.md F11) - customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, otpProcessor) + customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, customerDeviceProcessor) paymentCodeProcessor := processor.NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(a.redisClient), customerPinProcessor) inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo) diff --git a/internal/processor/customer_pin_processor.go b/internal/processor/customer_pin_processor.go index 447e9da..0de4f19 100644 --- a/internal/processor/customer_pin_processor.go +++ b/internal/processor/customer_pin_processor.go @@ -100,24 +100,22 @@ type pinOtpSender interface { ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error) } -// pinAlerter tells a customer their PIN was locked. There is no push channel to -// customers yet, so the app sends it by WhatsApp. -type pinAlerter interface { - SendWhatsAppMessage(phoneNumber, message string) error -} +// NotificationTypePinLocked is the data type of the push a customer gets when their +// PIN locks, so the app can offer the PIN reset. +const NotificationTypePinLocked = "PIN_LOCKED" // CustomerPinProcessor manages customer PINs (docs/prd-point-coin.md F11). Every flow // that moves balance on the customer's request calls VerifyPin first (K8). type CustomerPinProcessor struct { - repo repository.CustomerPinRepository - otp pinOtpSender - alerter pinAlerter - now func() time.Time - cost int + repo repository.CustomerPinRepository + otp pinOtpSender + notifier customerNotifier + now func() time.Time + cost int } -func NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, alerter pinAlerter) *CustomerPinProcessor { - return &CustomerPinProcessor{repo: repo, otp: otp, alerter: alerter, now: time.Now, cost: bcrypt.DefaultCost} +func NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, notifier customerNotifier) *CustomerPinProcessor { + return &CustomerPinProcessor{repo: repo, otp: otp, notifier: notifier, now: time.Now, cost: bcrypt.DefaultCost} } func (p *CustomerPinProcessor) Status(ctx context.Context, customerID uuid.UUID) (*models.CustomerPinStatus, error) { @@ -295,7 +293,7 @@ func (p *CustomerPinProcessor) verify(ctx context.Context, state *repository.Cus // customer; attempts racing it just see the lock. if attempts == pinMaxAttempts { p.logEvent(ctx, state.CustomerID, PinEventLocked, nil, nil, info) - p.alertLocked(state, *lockedUntil) + p.alertLocked(ctx, state, *lockedUntil) } return &PinError{Code: PinErrLocked, Until: lockedUntil} } @@ -420,13 +418,19 @@ func (p *CustomerPinProcessor) logEvent(ctx context.Context, customerID uuid.UUI } } -func (p *CustomerPinProcessor) alertLocked(state *repository.CustomerPinState, until time.Time) { - if p.alerter == nil || state.PhoneNumber == nil { +// alertLocked pushes the lock to the customer's app through FCM (F11). It is best +// effort: the lock stands whether or not the push goes out. +func (p *CustomerPinProcessor) alertLocked(ctx context.Context, state *repository.CustomerPinState, until time.Time) { + if p.notifier == nil { return } - message := fmt.Sprintf("PIN EnakPoint kamu terkunci sampai %s karena salah dimasukkan %d kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.", + body := fmt.Sprintf("PIN EnakPoint kamu terkunci sampai %s karena salah dimasukkan %d kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.", until.In(walletDisplayLocation).Format("02 Jan 2006 15:04 WIB"), pinMaxAttempts) - if err := p.alerter.SendWhatsAppMessage(*state.PhoneNumber, message); err != nil { + data := map[string]string{ + "type": NotificationTypePinLocked, + "locked_until": until.UTC().Format(time.RFC3339), + } + if err := p.notifier.Notify(ctx, state.CustomerID, "PIN terkunci", body, data); err != nil { logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s their PIN is locked", state.CustomerID), err) } } diff --git a/internal/processor/customer_pin_processor_db_test.go b/internal/processor/customer_pin_processor_db_test.go index 7167972..ca0e8d0 100644 --- a/internal/processor/customer_pin_processor_db_test.go +++ b/internal/processor/customer_pin_processor_db_test.go @@ -68,10 +68,10 @@ type alerterFake struct { messages []string } -func (f *alerterFake) SendWhatsAppMessage(_ string, message string) error { +func (f *alerterFake) Notify(_ context.Context, _ uuid.UUID, _, body string, _ map[string]string) error { f.mu.Lock() defer f.mu.Unlock() - f.messages = append(f.messages, message) + f.messages = append(f.messages, body) return nil } diff --git a/internal/processor/customer_pin_processor_test.go b/internal/processor/customer_pin_processor_test.go index b2d5ac7..f99a593 100644 --- a/internal/processor/customer_pin_processor_test.go +++ b/internal/processor/customer_pin_processor_test.go @@ -1,10 +1,17 @@ package processor import ( + "context" "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" ) func TestCheckNewPin(t *testing.T) { @@ -14,18 +21,18 @@ func TestCheckNewPin(t *testing.T) { assert.NoError(t, checkNewPin(ok, ok, &birth), ok) } for name, c := range map[string][2]string{ - "too short": {"12345", "12345"}, - "too long": {"1234567", "1234567"}, - "not digits": {"12a456", "12a456"}, - "confirmation": {"482913", "482914"}, - "one digit": {"111111", "111111"}, - "zeros": {"000000", "000000"}, - "run up": {"123456", "123456"}, - "run up from 4": {"456789", "456789"}, - "run down": {"654321", "654321"}, - "run down from 9": {"987654", "987654"}, - "birth date DDMMYY": {"140390", "140390"}, - "birth date YYMMDD": {"900314", "900314"}, + "too short": {"12345", "12345"}, + "too long": {"1234567", "1234567"}, + "not digits": {"12a456", "12a456"}, + "confirmation": {"482913", "482914"}, + "one digit": {"111111", "111111"}, + "zeros": {"000000", "000000"}, + "run up": {"123456", "123456"}, + "run up from 4": {"456789", "456789"}, + "run down": {"654321", "654321"}, + "run down from 9": {"987654", "987654"}, + "birth date DDMMYY": {"140390", "140390"}, + "birth date YYMMDD": {"900314", "900314"}, } { err := checkNewPin(c[0], c[1], &birth) assert.ErrorIs(t, err, ErrInvalidPinInput, name) @@ -34,3 +41,67 @@ func TestCheckNewPin(t *testing.T) { // Without a birth date only the other rules apply. assert.NoError(t, checkNewPin("140390", "140390", nil)) } + +// pinRepoFake holds one customer's PIN state, with the lock rules of RecordFailure. +type pinRepoFake struct { + repository.CustomerPinRepository + state repository.CustomerPinState +} + +func (f *pinRepoFake) GetState(context.Context, uuid.UUID) (*repository.CustomerPinState, error) { + s := f.state + return &s, nil +} + +func (f *pinRepoFake) RecordFailure(_ context.Context, _ uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) { + if f.state.LockedUntil != nil && !f.state.LockedUntil.After(now) { + f.state.FailedAttempts, f.state.LockedUntil = 1, nil + } else { + f.state.FailedAttempts++ + if f.state.FailedAttempts >= maxAttempts { + f.state.LockedUntil = &lockUntil + } + } + return f.state.FailedAttempts, f.state.LockedUntil, nil +} + +func (f *pinRepoFake) ClearFailures(context.Context, uuid.UUID) error { + f.state.FailedAttempts, f.state.LockedUntil = 0, nil + return nil +} + +func (f *pinRepoFake) InsertEvent(context.Context, repository.CustomerSecurityEvent) error { + return nil +} + +func TestCustomerPin_LockIsPushedThroughFCM(t *testing.T) { + customer := uuid.New() + hash, err := bcrypt.GenerateFromPassword([]byte("482913"), bcrypt.MinCost) + require.NoError(t, err) + h := string(hash) + repo := &pinRepoFake{state: repository.CustomerPinState{CustomerID: customer, PinHash: &h}} + notifier := ¬ifierFake{} + p := NewCustomerPinProcessor(repo, nil, notifier) + now := time.Date(2026, 9, 30, 3, 0, 0, 0, time.UTC) + p.now = func() time.Time { return now } + ctx := context.Background() + + for i := 0; i < 4; i++ { + _ = p.VerifyPin(ctx, customer, "000000", PinActionPay, models.CustomerPinRequestInfo{}) + } + assert.Empty(t, notifier.pushes[customer], "no push before the PIN locks") + + err = p.VerifyPin(ctx, customer, "000000", PinActionPay, models.CustomerPinRequestInfo{}) + var pinErr *PinError + require.ErrorAs(t, err, &pinErr) + assert.Equal(t, PinErrLocked, pinErr.Code) + + // Attempts while locked do not push again. + _ = p.VerifyPin(ctx, customer, "482913", PinActionPay, models.CustomerPinRequestInfo{}) + + require.Len(t, notifier.pushes[customer], 1) + push := notifier.pushes[customer][0] + assert.Equal(t, "PIN terkunci", push.title) + assert.Equal(t, "PIN EnakPoint kamu terkunci sampai 30 Sep 2026 10:30 WIB karena salah dimasukkan 5 kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.", push.body) + assert.Equal(t, map[string]string{"type": NotificationTypePinLocked, "locked_until": "2026-09-30T03:30:00Z"}, push.data) +} diff --git a/internal/processor/otp_processor.go b/internal/processor/otp_processor.go index b53d297..28013aa 100644 --- a/internal/processor/otp_processor.go +++ b/internal/processor/otp_processor.go @@ -18,8 +18,6 @@ type OtpProcessor interface { CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) ResendOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error - // SendWhatsAppMessage sends any message to a customer number, formatted like OTPs. - SendWhatsAppMessage(phoneNumber string, message string) error ValidateOtpCode(code string) bool ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error) InvalidateOtpSession(ctx context.Context, token string) error @@ -242,10 +240,3 @@ func (p *otpProcessor) formatPhoneNumber(phoneNumber string) string { return digits } - -func (p *otpProcessor) SendWhatsAppMessage(phoneNumber string, message string) error { - if err := p.fonnteClient.SendWhatsAppMessage(p.formatPhoneNumber(phoneNumber), message); err != nil { - return fmt.Errorf("failed to send WhatsApp message: %w", err) - } - return nil -} -- 2.54.0 From 9ce55e6002a75f5e84d14f46537a58881f473ca5 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 13:23:40 +0700 Subject: [PATCH 40/47] feat(loyalty): expiry settings for both expiry models Settles note N4 of docs/prd-point-coin.md: both expiry models are supported, chosen per currency by the owner, defaulting to one fixed date a year (PC-501, F12). New organization keys, per currency (loyalty.point.* / loyalty.coin.*): - expiry_mode: FIXED_DATE (default) or ROLLING. - expiry_fixed_dates: the days of the year balances expire on, as sorted MM-DD values ("12-31" by default, "06-30,12-31" for twice a year). 29 Feb is refused. - expiry_grace_months: 0 to 24, default 3. A balance lasts at least this long before a fixed date takes it. The existing period, unit and end_of_month keys now belong to ROLLING, and reminder_days to both. ComputeExpiry gives the expiry of a balance received at a time: the first fixed date on or after the day received plus the grace months, or the day received plus the period (to the end of that month when asked). Days are the customer's (WIB), a shorter month keeps to its last day, and a lot lasts to 23:59:59 of its day so the apps group it under that day. Nil when expiry is off. ActivationExpiry, RefundExpiry and EarlierExpiry hold the other decided rules and are used by PC-502. GET and PUT /marketing/loyalty-settings return expiry_preview: when a balance received now would expire, for the dashboard's "received today expires on ..." hint, also on a dry run. Co-Authored-By: Claude Opus 5.5 --- internal/constants/loyalty.go | 15 ++ internal/models/loyalty.go | 39 +++- internal/processor/loyalty_expiry.go | 170 ++++++++++++++++++ internal/processor/loyalty_expiry_test.go | 115 ++++++++++++ .../processor/loyalty_settings_processor.go | 44 +++++ .../loyalty_settings_processor_test.go | 12 +- internal/service/loyalty_settings_service.go | 17 +- 7 files changed, 398 insertions(+), 14 deletions(-) create mode 100644 internal/processor/loyalty_expiry.go create mode 100644 internal/processor/loyalty_expiry_test.go diff --git a/internal/constants/loyalty.go b/internal/constants/loyalty.go index 14ac2e6..6721edf 100644 --- a/internal/constants/loyalty.go +++ b/internal/constants/loyalty.go @@ -42,6 +42,9 @@ const ( LoyaltyExpiryUnitSuffix = "expiry_unit" LoyaltyExpiryEndOfMonthSuffix = "expiry_end_of_month" LoyaltyExpiryReminderDaysSuffix = "expiry_reminder_days" + LoyaltyExpiryModeSuffix = "expiry_mode" + LoyaltyExpiryFixedDatesSuffix = "expiry_fixed_dates" + LoyaltyExpiryGraceMonthsSuffix = "expiry_grace_months" ) // Units of loyalty.{point,coin}.expiry_unit. @@ -50,6 +53,14 @@ const ( LoyaltyExpiryUnitMonth = "MONTH" ) +// Modes of loyalty.{point,coin}.expiry_mode (note N4, decided: both are supported). +const ( + // Every balance expires on the same fixed dates of the year, such as 31 Dec. + LoyaltyExpiryModeFixedDate = "FIXED_DATE" + // Every balance lasts a period from when it was received. + LoyaltyExpiryModeRolling = "ROLLING" +) + // Defaults from the PRD. const ( LoyaltyPointEarnPerAmountDefault = int64(100) @@ -68,4 +79,8 @@ const ( LoyaltyExpiryPeriodDefault = int64(12) LoyaltyExpiryUnitDefault = LoyaltyExpiryUnitMonth LoyaltyExpiryReminderDaysDefault = int64(7) + LoyaltyExpiryModeDefault = LoyaltyExpiryModeFixedDate + // Once a year on 31 December, as MM-DD. + LoyaltyExpiryFixedDatesDefault = "12-31" + LoyaltyExpiryGraceMonthsDefault = int64(3) ) diff --git a/internal/models/loyalty.go b/internal/models/loyalty.go index 1d168ba..f7e0e1e 100644 --- a/internal/models/loyalty.go +++ b/internal/models/loyalty.go @@ -57,15 +57,29 @@ type LoyaltyTransferSettings struct { DailyLimit *int64 `json:"daily_limit"` } -// LoyaltyExpirySettings is how long one currency lasts once received. The expiry -// model is still open (note N4); these are only the stored settings. +// LoyaltyExpirySettings is when one currency expires once received (F12). Both +// models of note N4 are supported, and the owner picks one: +// +// - FIXED_DATE: everything expires on the next of FixedDates falling on or after +// the day received + GraceMonths, so a balance received just before a date moves +// on to the one after. +// - ROLLING: everything lasts Period Units from the day received, to the end of +// that month when EndOfMonth is set. type LoyaltyExpirySettings struct { - Enabled bool `json:"enabled"` - Period int64 `json:"period"` - // DAY or MONTH. - Unit string `json:"unit"` - EndOfMonth bool `json:"end_of_month"` - ReminderDays int64 `json:"reminder_days"` + Enabled bool `json:"enabled"` + // FIXED_DATE or ROLLING. + Mode string `json:"mode"` + // FIXED_DATE: the days of the year balances expire on, as MM-DD, sorted. + FixedDates []string `json:"fixed_dates"` + // FIXED_DATE: how many months a balance lasts at least before a fixed date takes it. + GraceMonths int64 `json:"grace_months"` + // ROLLING: how long a balance lasts. + Period int64 `json:"period"` + // ROLLING: DAY or MONTH. + Unit string `json:"unit"` + EndOfMonth bool `json:"end_of_month"` + // Days before expiry the customer is reminded; 0 for no reminder. + ReminderDays int64 `json:"reminder_days"` } // LoyaltySettingChange is one row of the loyalty settings history. @@ -109,12 +123,21 @@ type OrganizationLoyaltySettingsView struct { OrganizationLoyaltySettings // What the balances in circulation are worth, before and after the change. Impact LoyaltySettingsImpact `json:"impact"` + // When a balance received now would expire under these settings (F12). + ExpiryPreview LoyaltyExpiryPreview `json:"expiry_preview"` // On PUT, the keys that changed; on a dry run, the keys that would. Changes []LoyaltySettingChange `json:"changes"` // True when nothing was saved. DryRun bool `json:"dry_run"` } +// LoyaltyExpiryPreview is what the dashboard shows next to the expiry settings: "the +// EnakPoint received today expire on …". Nil means they never expire. +type LoyaltyExpiryPreview struct { + Point *time.Time `json:"point"` + Coin *time.Time `json:"coin"` +} + // LoyaltySettingsImpact shows how a change of point value or exchange rate changes what // the balances in circulation are worth (F2). Before and after are equal when neither // changes. diff --git a/internal/processor/loyalty_expiry.go b/internal/processor/loyalty_expiry.go new file mode 100644 index 0000000..cf05322 --- /dev/null +++ b/internal/processor/loyalty_expiry.go @@ -0,0 +1,170 @@ +package processor + +import ( + "errors" + "fmt" + "sort" + "strings" + "time" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" +) + +// Expiry rules of docs/prd-point-coin.md F12, with the model of note N4 decided: both +// FIXED_DATE and ROLLING are supported and the owner picks one per currency. +// +// A lot expires at the last second of its expiry day in the customer's time zone, so +// "expires on 31 Dec" means it can still be spent all of 31 Dec, and the day the apps +// group it under is that day. + +const ( + // A longer grace would push a balance past a whole cycle of fixed dates. + loyaltyExpiryMaxGraceMonths = 24 + // A refund gives the EnakPoint back at least this long, even when the lot they + // came from has expired or is about to (note N4, decided). + refundMinimumValidity = 7 * 24 * time.Hour +) + +// ExpirySettings picks the settings of one currency. +func ExpirySettings(s *models.OrganizationLoyaltySettings, currency string) models.LoyaltyExpirySettings { + if currency == constants.WalletCurrencyCoin { + return s.CoinExpiry + } + return s.PointExpiry +} + +// ComputeExpiry is when a balance received at receivedAt expires, or nil when it never +// does because expiry is off. It is the expiry of every lot that is not carried over +// from another one: EARN, ADJUSTMENT and, capped by its origin, EXCHANGE_IN. +func ComputeExpiry(s models.LoyaltyExpirySettings, receivedAt time.Time) *time.Time { + if !s.Enabled { + return nil + } + day := walletDay(receivedAt) + if s.Mode == constants.LoyaltyExpiryModeRolling { + if s.Unit == constants.LoyaltyExpiryUnitDay { + day = day.AddDate(0, 0, int(s.Period)) + } else { + day = addMonthsClamped(day, int(s.Period)) + } + if s.EndOfMonth { + day = lastDayOfMonth(day) + } + return endOfWalletDay(day) + } + return endOfWalletDay(fixedDateOnOrAfter(s.FixedDates, addMonthsClamped(day, int(s.GraceMonths)))) +} + +// ActivationExpiry is the expiry given to the balances that already exist, without an +// expiry, when expiry is turned on (note N4, decided): a full period from activation +// when ROLLING, the second fixed date after activation when FIXED_DATE. Either way no +// customer loses a balance soon after the rule is announced. +func ActivationExpiry(s models.LoyaltyExpirySettings, at time.Time) *time.Time { + if s.Mode == constants.LoyaltyExpiryModeRolling { + s.Enabled = true + return ComputeExpiry(s, at) + } + first := fixedDateOnOrAfter(s.FixedDates, walletDay(at)) + return endOfWalletDay(fixedDateOnOrAfter(s.FixedDates, first.AddDate(0, 0, 1))) +} + +// RefundExpiry is the expiry of EnakPoint given back by a refund: that of the lot they +// came from, but at least seven days from now (note N4, decided). A lot that never +// expires stays so. +func RefundExpiry(origin *time.Time, now time.Time) *time.Time { + if origin == nil { + return nil + } + minimum := endOfWalletDay(walletDay(now.Add(refundMinimumValidity))) + if origin.Before(*minimum) { + return minimum + } + return origin +} + +// EarlierExpiry is the sooner of two expiries, where nil means never. +func EarlierExpiry(a, b *time.Time) *time.Time { + switch { + case a == nil: + return b + case b == nil: + return a + case b.Before(*a): + return b + } + return a +} + +// normalizeDaysOfYear checks days written as MM-DD and returns them sorted without +// duplicates. 29 February is refused, as it does not come every year. +func normalizeDaysOfYear(days []string) ([]string, error) { + seen := map[string]bool{} + var out []string + for _, d := range days { + d = strings.TrimSpace(d) + if d == "" { + continue + } + parsed, err := time.Parse("01-02", d) + if err != nil || parsed.Format("01-02") != d { + return nil, fmt.Errorf("has %q, which is not a day of the year written as MM-DD", d) + } + if d == "02-29" { + return nil, errors.New("cannot use 02-29, which is not in every year") + } + if !seen[d] { + seen[d] = true + out = append(out, d) + } + } + if len(out) == 0 { + return nil, errors.New("must list at least one day of the year, as MM-DD") + } + sort.Strings(out) + return out, nil +} + +// fixedDateOnOrAfter is the first of the days of the year that falls on or after day. +func fixedDateOnOrAfter(days []string, day time.Time) time.Time { + days, err := normalizeDaysOfYear(days) + if err != nil { + days = []string{constants.LoyaltyExpiryFixedDatesDefault} + } + for year := day.Year(); ; year++ { + for _, d := range days { + parsed, _ := time.Parse("01-02", d) + candidate := time.Date(year, parsed.Month(), parsed.Day(), 0, 0, 0, 0, walletDisplayLocation) + if !candidate.Before(day) { + return candidate + } + } + } +} + +// walletDay is the calendar day of t in the customer's time zone, at midnight. +func walletDay(t time.Time) time.Time { + local := t.In(walletDisplayLocation) + return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, walletDisplayLocation) +} + +func endOfWalletDay(day time.Time) *time.Time { + end := time.Date(day.Year(), day.Month(), day.Day(), 23, 59, 59, 0, walletDisplayLocation) + return &end +} + +// addMonthsClamped adds months, keeping to the last day of a shorter month: 31 Jan +// plus one month is 28 or 29 Feb, not 3 Mar. +func addMonthsClamped(day time.Time, months int) time.Time { + first := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, day.Location()).AddDate(0, months, 0) + last := lastDayOfMonth(first).Day() + d := day.Day() + if d > last { + d = last + } + return time.Date(first.Year(), first.Month(), d, 0, 0, 0, 0, day.Location()) +} + +func lastDayOfMonth(day time.Time) time.Time { + return time.Date(day.Year(), day.Month()+1, 0, 0, 0, 0, 0, day.Location()) +} diff --git a/internal/processor/loyalty_expiry_test.go b/internal/processor/loyalty_expiry_test.go new file mode 100644 index 0000000..c112f30 --- /dev/null +++ b/internal/processor/loyalty_expiry_test.go @@ -0,0 +1,115 @@ +package processor + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/models" +) + +// wib is a moment in the customer's time zone. +func wib(year int, month time.Month, day, hour, minute int) time.Time { + return time.Date(year, month, day, hour, minute, 0, 0, walletDisplayLocation) +} + +// expiryDay is the day a lot expires on, as the apps show it. +func expiryDay(t *testing.T, at *time.Time) string { + t.Helper() + require.NotNil(t, at) + local := at.In(walletDisplayLocation) + assert.Equal(t, "23:59:59", local.Format("15:04:05"), "a lot lasts to the end of its day") + return local.Format("2006-01-02") +} + +func fixedDate(dates ...string) models.LoyaltyExpirySettings { + return models.LoyaltyExpirySettings{Enabled: true, Mode: "FIXED_DATE", FixedDates: dates, GraceMonths: 3} +} + +func rolling(period int64, unit string, endOfMonth bool) models.LoyaltyExpirySettings { + return models.LoyaltyExpirySettings{Enabled: true, Mode: "ROLLING", Period: period, Unit: unit, EndOfMonth: endOfMonth} +} + +func TestComputeExpiry_OffNeverExpires(t *testing.T) { + s := fixedDate("12-31") + s.Enabled = false + assert.Nil(t, ComputeExpiry(s, wib(2026, 5, 1, 10, 0))) +} + +func TestComputeExpiry_FixedDateWithGrace(t *testing.T) { + yearly := fixedDate("12-31") + for received, want := range map[time.Time]string{ + wib(2026, 1, 15, 9, 0): "2026-12-31", + wib(2026, 9, 30, 23, 0): "2026-12-31", // 30 Dec + nothing: still this year + wib(2026, 10, 1, 0, 30): "2027-12-31", // within 3 months of 31 Dec: next year + wib(2026, 12, 31, 8, 0): "2027-12-31", + } { + assert.Equal(t, want, expiryDay(t, ComputeExpiry(yearly, received)), received.String()) + } + + twice := fixedDate("12-31", "06-30") + assert.Equal(t, "2026-06-30", expiryDay(t, ComputeExpiry(twice, wib(2026, 3, 1, 12, 0)))) + assert.Equal(t, "2026-12-31", expiryDay(t, ComputeExpiry(twice, wib(2026, 4, 1, 12, 0)))) + assert.Equal(t, "2027-06-30", expiryDay(t, ComputeExpiry(twice, wib(2026, 10, 1, 12, 0)))) + + noGrace := fixedDate("12-31") + noGrace.GraceMonths = 0 + assert.Equal(t, "2026-12-31", expiryDay(t, ComputeExpiry(noGrace, wib(2026, 12, 31, 22, 0)))) +} + +// The day received is the customer's day: 20:00 UTC on 31 Mar is already 1 Apr in WIB. +func TestComputeExpiry_UsesTheCustomersDay(t *testing.T) { + received := time.Date(2026, 3, 31, 20, 0, 0, 0, time.UTC) + assert.Equal(t, "2026-04-11", expiryDay(t, ComputeExpiry(rolling(10, "DAY", false), received))) +} + +func TestComputeExpiry_Rolling(t *testing.T) { + assert.Equal(t, "2027-03-14", expiryDay(t, ComputeExpiry(rolling(12, "MONTH", false), wib(2026, 3, 14, 10, 0)))) + assert.Equal(t, "2027-03-31", expiryDay(t, ComputeExpiry(rolling(12, "MONTH", true), wib(2026, 3, 14, 10, 0)))) + assert.Equal(t, "2026-03-24", expiryDay(t, ComputeExpiry(rolling(10, "DAY", false), wib(2026, 3, 14, 10, 0)))) + // A month that is shorter keeps to its last day. + assert.Equal(t, "2026-02-28", expiryDay(t, ComputeExpiry(rolling(1, "MONTH", false), wib(2026, 1, 31, 10, 0)))) +} + +func TestActivationExpiry(t *testing.T) { + // FIXED_DATE: the second fixed date after activation, so nobody loses a balance + // weeks after the rule is announced. + assert.Equal(t, "2027-12-31", expiryDay(t, ActivationExpiry(fixedDate("12-31"), wib(2026, 11, 20, 10, 0)))) + assert.Equal(t, "2027-12-31", expiryDay(t, ActivationExpiry(fixedDate("12-31"), wib(2026, 12, 31, 10, 0)))) + assert.Equal(t, "2027-06-30", expiryDay(t, ActivationExpiry(fixedDate("06-30", "12-31"), wib(2026, 11, 20, 10, 0)))) + // ROLLING: a full period from activation. + assert.Equal(t, "2027-11-20", expiryDay(t, ActivationExpiry(rolling(12, "MONTH", false), wib(2026, 11, 20, 10, 0)))) +} + +func TestRefundExpiry(t *testing.T) { + now := wib(2026, 6, 1, 10, 0) + later := wib(2026, 12, 31, 23, 59) + soon := wib(2026, 6, 3, 23, 59) + past := wib(2026, 5, 1, 23, 59) + + assert.Nil(t, RefundExpiry(nil, now)) + assert.Equal(t, &later, RefundExpiry(&later, now)) + assert.Equal(t, "2026-06-08", expiryDay(t, RefundExpiry(&soon, now))) + assert.Equal(t, "2026-06-08", expiryDay(t, RefundExpiry(&past, now))) +} + +func TestEarlierExpiry(t *testing.T) { + a, b := wib(2026, 1, 1, 0, 0), wib(2026, 2, 1, 0, 0) + assert.Equal(t, &a, EarlierExpiry(&a, &b)) + assert.Equal(t, &a, EarlierExpiry(&b, &a)) + assert.Equal(t, &a, EarlierExpiry(nil, &a)) + assert.Equal(t, &a, EarlierExpiry(&a, nil)) + assert.Nil(t, EarlierExpiry(nil, nil)) +} + +func TestNormalizeDaysOfYear(t *testing.T) { + days, err := normalizeDaysOfYear([]string{" 12-31", "06-30", "12-31", ""}) + require.NoError(t, err) + assert.Equal(t, []string{"06-30", "12-31"}, days) + for _, bad := range [][]string{{}, {"13-01"}, {"12-32"}, {"1-31"}, {"02-29"}, {"31-12"}} { + _, err := normalizeDaysOfYear(bad) + assert.Error(t, err, bad) + } +} diff --git a/internal/processor/loyalty_settings_processor.go b/internal/processor/loyalty_settings_processor.go index 4c67363..a470b42 100644 --- a/internal/processor/loyalty_settings_processor.go +++ b/internal/processor/loyalty_settings_processor.go @@ -236,6 +236,10 @@ func organizationLoyaltyFields(s *models.OrganizationLoyaltySettings) []loyaltyF func expiryLoyaltyFields(prefix string, s *models.LoyaltyExpirySettings) []loyaltyField { return []loyaltyField{ boolLoyaltyField(prefix+constants.LoyaltyExpiryEnabledSuffix, &s.Enabled, false), + enumLoyaltyField(prefix+constants.LoyaltyExpiryModeSuffix, &s.Mode, constants.LoyaltyExpiryModeDefault, + constants.LoyaltyExpiryModeFixedDate, constants.LoyaltyExpiryModeRolling), + dayOfYearListLoyaltyField(prefix+constants.LoyaltyExpiryFixedDatesSuffix, &s.FixedDates, constants.LoyaltyExpiryFixedDatesDefault), + intLoyaltyField(prefix+constants.LoyaltyExpiryGraceMonthsSuffix, &s.GraceMonths, constants.LoyaltyExpiryGraceMonthsDefault, 0, loyaltyExpiryMaxGraceMonths), intLoyaltyField(prefix+constants.LoyaltyExpiryPeriodSuffix, &s.Period, constants.LoyaltyExpiryPeriodDefault, 1, noLoyaltyMax), enumLoyaltyField(prefix+constants.LoyaltyExpiryUnitSuffix, &s.Unit, constants.LoyaltyExpiryUnitDefault, constants.LoyaltyExpiryUnitDay, constants.LoyaltyExpiryUnitMonth), @@ -362,6 +366,46 @@ func enumLoyaltyField(key string, v *string, def string, allowed ...string) loya } } +// dayOfYearListLoyaltyField is a non-empty set of days of the year, stored as sorted +// MM-DD values joined by commas, such as "06-30,12-31". +func dayOfYearListLoyaltyField(key string, v *[]string, def string) loyaltyField { + normalize := func(days []string) ([]string, error) { + out, err := normalizeDaysOfYear(days) + if err != nil { + return nil, fmt.Errorf("%w: %s %v", ErrInvalidLoyaltySettings, key, err) + } + return out, nil + } + return loyaltyField{ + key: key, + parse: func(raw string) bool { + days, err := normalize(strings.Split(raw, ",")) + if err != nil { + return false + } + *v = days + return true + }, + reset: func() { *v, _ = normalizeDaysOfYear(strings.Split(def, ",")) }, + validate: func() error { + days, err := normalize(*v) + if err != nil { + return err + } + *v = days + return nil + }, + format: func() *string { + days, err := normalizeDaysOfYear(*v) + if err != nil { + days = *v + } + s := strings.Join(days, ",") + return &s + }, + } +} + // loadLoyaltyFields fills each field from its stored value, or its default when there // is none. A stored value that cannot be used also falls back to the default, so a bad // row can never reach a calculation, and it is logged so it gets fixed. diff --git a/internal/processor/loyalty_settings_processor_test.go b/internal/processor/loyalty_settings_processor_test.go index 2cb1d9d..4ee552c 100644 --- a/internal/processor/loyalty_settings_processor_test.go +++ b/internal/processor/loyalty_settings_processor_test.go @@ -113,7 +113,7 @@ func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing. p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{}) s, err := p.Organization(context.Background(), uuid.New()) require.NoError(t, err) - expiry := models.LoyaltyExpirySettings{Enabled: false, Period: 12, Unit: "MONTH", EndOfMonth: false, ReminderDays: 7} + expiry := models.LoyaltyExpirySettings{Enabled: false, Mode: "FIXED_DATE", FixedDates: []string{"12-31"}, GraceMonths: 3, Period: 12, Unit: "MONTH", EndOfMonth: false, ReminderDays: 7} assert.Equal(t, models.OrganizationLoyaltySettings{ PointValue: 1, Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}, @@ -136,6 +136,8 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) { constants.LoyaltyPointValueKey: "100", constants.LoyaltyTransferDailyLimitKey: "10000", "loyalty.coin.expiry_unit": "day", + "loyalty.point.expiry_fixed_dates": "12-31, 06-30,12-31", + "loyalty.coin.expiry_mode": "rolling", }, } p := NewLoyaltySettingsProcessor(repo, txRunnerFake{}) @@ -153,6 +155,9 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) { assert.Equal(t, int64(10000), *org.Transfer.DailyLimit) assert.Equal(t, "DAY", org.CoinExpiry.Unit) assert.Equal(t, "MONTH", org.PointExpiry.Unit) + assert.Equal(t, []string{"06-30", "12-31"}, org.PointExpiry.FixedDates) + assert.Equal(t, "ROLLING", org.CoinExpiry.Mode) + assert.Equal(t, "FIXED_DATE", org.PointExpiry.Mode) } // A bad row must never reach a calculation: earn_per_amount 0 would divide by zero. @@ -302,6 +307,11 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) { "expiry period 0": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Period = 0 }, "expiry unit YEAR": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.Unit = "YEAR" }, "negative reminder days": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.ReminderDays = -1 }, + "expiry mode YEARLY": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Mode = "YEARLY" }, + "no fixed dates": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.FixedDates = nil }, + "fixed date 13-01": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.FixedDates = []string{"13-01"} }, + "fixed date 02-29": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.FixedDates = []string{"02-29"} }, + "negative grace": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.GraceMonths = -1 }, } { s, err := p.Organization(ctx, org) require.NoError(t, err) diff --git a/internal/service/loyalty_settings_service.go b/internal/service/loyalty_settings_service.go index aa87f5d..a53708c 100644 --- a/internal/service/loyalty_settings_service.go +++ b/internal/service/loyalty_settings_service.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "time" "github.com/google/uuid" @@ -144,9 +145,10 @@ func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context // of the GET response, which are ignored. type organizationSettingsInput struct { *models.OrganizationLoyaltySettings - Impact json.RawMessage `json:"impact"` - Changes json.RawMessage `json:"changes"` - DryRun json.RawMessage `json:"dry_run"` + Impact json.RawMessage `json:"impact"` + ExpiryPreview json.RawMessage `json:"expiry_preview"` + Changes json.RawMessage `json:"changes"` + DryRun json.RawMessage `json:"dry_run"` } func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo, body []byte, dryRun bool) *contract.Response { @@ -196,6 +198,7 @@ func (s *LoyaltySettingsServiceImpl) ListHistory(ctx context.Context, apctx *app } func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, changes []models.LoyaltySettingChange, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) { + now := time.Now() points, coins, err := s.outstanding.OrganizationOutstanding(ctx, organizationID) if err != nil { return nil, err @@ -206,7 +209,11 @@ func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organ return &models.OrganizationLoyaltySettingsView{ OrganizationLoyaltySettings: after, Impact: models.NewLoyaltySettingsImpact(points, coins, before, after), - Changes: changes, - DryRun: dryRun, + ExpiryPreview: models.LoyaltyExpiryPreview{ + Point: processor.ComputeExpiry(after.PointExpiry, now), + Coin: processor.ComputeExpiry(after.CoinExpiry, now), + }, + Changes: changes, + DryRun: dryRun, }, nil } -- 2.54.0 From 4d63673a25774cac3aa52e2ee44384b72b94014a Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 13:29:25 +0700 Subject: [PATCH 41/47] feat(loyalty): give new balances their expiry Every lot now gets its expiry when it is created (docs/prd-point-coin.md F12, PC-502), where it used to never expire until note N4 was settled: - EARN and an ADJUSTMENT that adds: ComputeExpiry of the organization's settings for that currency, from the moment received. - EXCHANGE_IN: the sooner of the EnakCoin lot's expiry and when EnakPoint received now expire (F4). - PAYMENT_REFUND: the expiry of the lot the EnakPoint came from, but at least seven days from the refund (N4, decided). A lot that never expired stays so. - TRANSFER_IN: unchanged, exactly the sender's expiry. Turning expiry on for a currency for the first time dates every lot of the organization that still holds something and has no expiry, MIGRATION lots included, in the same transaction as the setting: a full period from now when ROLLING, the second fixed date on or after today when FIXED_DATE, so no customer loses a balance soon after the rule is announced (N4, decided). Turning it off leaves dated lots as they are. PUT /marketing/loyalty-settings reports these as expiry_activations (currency, lots, amount, expires_at); a dry run counts them without dating anything. The earning processor now also reads the organization settings, and the wallet admin processor takes the settings reader. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 2 +- internal/handler/loyalty_settings_db_test.go | 2 +- internal/handler/wallet_admin_db_test.go | 2 +- internal/models/loyalty.go | 11 ++++ internal/processor/earning_processor.go | 51 ++++++++++----- .../processor/earning_processor_db_test.go | 6 +- .../processor/loyalty_settings_processor.go | 62 ++++++++++++++++--- .../loyalty_settings_processor_test.go | 57 ++++++++++++++++- internal/processor/point_payment_refund.go | 6 +- internal/processor/point_refund_db_test.go | 2 +- internal/processor/wallet_admin_processor.go | 32 ++++++---- .../processor/wallet_exchange_processor.go | 11 ++-- .../wallet_exchange_processor_test.go | 20 ++++++ .../repository/loyalty_settings_repository.go | 43 +++++++++++++ .../loyalty_settings_repository_test.go | 2 +- internal/service/loyalty_settings_service.go | 23 ++++--- 16 files changed, 269 insertions(+), 63 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 91862c4..ee0c19b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -480,7 +480,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor walletTransferProcessor: walletTransferProcessor, walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)), customerDeviceProcessor: customerDeviceProcessor, - walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), + walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), loyaltySettingsProcessor, repos.txManager), } } diff --git a/internal/handler/loyalty_settings_db_test.go b/internal/handler/loyalty_settings_db_test.go index d776ba1..c427002 100644 --- a/internal/handler/loyalty_settings_db_test.go +++ b/internal/handler/loyalty_settings_db_test.go @@ -113,7 +113,7 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) { orgSettings, err := settings.Organization(context.Background(), org) require.NoError(t, err) orgSettings.PointValue = 5 - _, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings) + _, _, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings) require.NoError(t, err) status, body = call(http.MethodGet, "/manager"+path, "") require.Equal(t, http.StatusOK, status, body) diff --git a/internal/handler/wallet_admin_db_test.go b/internal/handler/wallet_admin_db_test.go index 5ca8677..7c4de20 100644 --- a/internal/handler/wallet_admin_db_test.go +++ b/internal/handler/wallet_admin_db_test.go @@ -74,7 +74,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) { })) h := NewWalletAdminHandler( - service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))), + service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm), txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))), validator.NewWalletValidator(), ) gin.SetMode(gin.TestMode) diff --git a/internal/models/loyalty.go b/internal/models/loyalty.go index f7e0e1e..3a0f8b1 100644 --- a/internal/models/loyalty.go +++ b/internal/models/loyalty.go @@ -125,12 +125,23 @@ type OrganizationLoyaltySettingsView struct { Impact LoyaltySettingsImpact `json:"impact"` // When a balance received now would expire under these settings (F12). ExpiryPreview LoyaltyExpiryPreview `json:"expiry_preview"` + // The currencies this change turns expiry on for, and the balances affected. + ExpiryActivations []LoyaltyExpiryActivation `json:"expiry_activations"` // On PUT, the keys that changed; on a dry run, the keys that would. Changes []LoyaltySettingChange `json:"changes"` // True when nothing was saved. DryRun bool `json:"dry_run"` } +// LoyaltyExpiryActivation is expiry being turned on for a currency: the balances that +// had no expiry and the expiry they get (F12). On a dry run nothing is dated yet. +type LoyaltyExpiryActivation struct { + Currency string `json:"currency"` + Lots int64 `json:"lots"` + Amount int64 `json:"amount"` + ExpiresAt time.Time `json:"expires_at"` +} + // LoyaltyExpiryPreview is what the dashboard shows next to the expiry settings: "the // EnakPoint received today expire on …". Nil means they never expire. type LoyaltyExpiryPreview struct { diff --git a/internal/processor/earning_processor.go b/internal/processor/earning_processor.go index 042a50a..b820ea1 100644 --- a/internal/processor/earning_processor.go +++ b/internal/processor/earning_processor.go @@ -36,17 +36,25 @@ type outletSettingsReader interface { Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) } +// earningSettingsReader gives the outlet's earning settings and the organization's +// expiry. LoyaltySettingsProcessor is one. +type earningSettingsReader interface { + outletSettingsReader + organizationSettingsReader +} + // EarningProcessor credits EnakPoint and EnakCoin for paid orders // (docs/prd-point-coin.md F3). type EarningProcessor struct { orders repository.EarningRepository - settings outletSettingsReader + settings earningSettingsReader + now func() time.Time wallet *WalletProcessor tx TxRunner } -func NewEarningProcessor(orders repository.EarningRepository, settings outletSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor { - return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx} +func NewEarningProcessor(orders repository.EarningRepository, settings earningSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor { + return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx, now: time.Now} } // OnOrderPaid is called once an order has become fully paid and the payment has @@ -87,6 +95,12 @@ func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID) return &EarningOutcome{Skipped: EarningSkipNothingToEarn}, nil } + orgSettings, err := p.settings.Organization(ctx, order.OrganizationID) + if err != nil { + return nil, err + } + receivedAt := p.now() + outcome := &EarningOutcome{} err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { for _, c := range []struct { @@ -101,19 +115,24 @@ func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID) continue } outletID := order.OutletID - res, err := p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{ - CustomerID: *order.CustomerID, - Currency: c.currency, - Type: constants.WalletTxTypeEarn, - Amount: c.line.Amount, - ReferenceType: constants.WalletRefTypeOrder, - ReferenceID: order.ID, - OutletID: &outletID, - Description: earningDescription(order), - Metadata: result.Metadata(c.line), - IdempotencyKey: fmt.Sprintf("earn:%s:%s", order.ID, c.currency), - // Lots never expire until the expiry model is decided (F12, note N4). - }}) + res, err := p.wallet.Credit(ctx, WalletCreditInput{ + WalletEntry: WalletEntry{ + CustomerID: *order.CustomerID, + Currency: c.currency, + Type: constants.WalletTxTypeEarn, + Amount: c.line.Amount, + ReferenceType: constants.WalletRefTypeOrder, + ReferenceID: order.ID, + OutletID: &outletID, + Description: earningDescription(order), + Metadata: result.Metadata(c.line), + IdempotencyKey: fmt.Sprintf("earn:%s:%s", order.ID, c.currency), + }, + Lots: []WalletLotInput{{ + Amount: c.line.Amount, + ExpiresAt: ComputeExpiry(ExpirySettings(orgSettings, c.currency), receivedAt), + }}, + }) if err != nil { return fmt.Errorf("crediting %s: %w", c.currency, err) } diff --git a/internal/processor/earning_processor_db_test.go b/internal/processor/earning_processor_db_test.go index 0a58841..7fad218 100644 --- a/internal/processor/earning_processor_db_test.go +++ b/internal/processor/earning_processor_db_test.go @@ -22,7 +22,7 @@ import ( // failingSettings fails for the outlet settings until healed, to stand in for the // database being unreachable right after a payment. type failingSettings struct { - real outletSettingsReader + real earningSettingsReader mu sync.Mutex fail bool } @@ -227,3 +227,7 @@ func TestEarningProcessor_AgainstPostgres(t *testing.T) { assert.Equal(t, int64(875+250+600+400), balance.PointBalance) assert.Equal(t, int64(3+1+2+1), balance.CoinBalance) } + +func (f *failingSettings) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) { + return f.real.Organization(ctx, organizationID) +} diff --git a/internal/processor/loyalty_settings_processor.go b/internal/processor/loyalty_settings_processor.go index a470b42..2b78e45 100644 --- a/internal/processor/loyalty_settings_processor.go +++ b/internal/processor/loyalty_settings_processor.go @@ -6,6 +6,7 @@ import ( "fmt" "strconv" "strings" + "time" "github.com/google/uuid" @@ -27,10 +28,11 @@ var ErrInvalidLoyaltySettings = errors.New("invalid loyalty settings") type LoyaltySettingsProcessor struct { repo repository.LoyaltySettingsRepository tx TxRunner + now func() time.Time } func NewLoyaltySettingsProcessor(repo repository.LoyaltySettingsRepository, tx TxRunner) *LoyaltySettingsProcessor { - return &LoyaltySettingsProcessor{repo: repo, tx: tx} + return &LoyaltySettingsProcessor{repo: repo, tx: tx, now: time.Now} } // Outlet returns an outlet's settings. @@ -111,11 +113,15 @@ func (p *LoyaltySettingsProcessor) UpdateOutlet(ctx context.Context, organizatio } // UpdateOrganization replaces an organization's settings and returns what changed. -func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organizationID, changedBy uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) { +// +// Turning expiry on for a currency for the first time also gives every existing lot +// without an expiry the activation expiry (F12, note N4), and the result lists them. +func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organizationID, changedBy uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, []models.LoyaltyExpiryActivation, error) { if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil { - return nil, err + return nil, nil, err } var changes []models.LoyaltySettingChange + var activations []models.LoyaltyExpiryActivation err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { if err := p.repo.LockScope(ctx, organizationID); err != nil { return err @@ -132,9 +138,42 @@ func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organ func(key string, value *string) error { return p.repo.SetOrganizationValue(ctx, organizationID, key, value) }) + if err != nil { + return err + } + activations, err = p.expiryActivations(ctx, organizationID, current, settings, true) return err }) - return changes, err + if err != nil { + return nil, nil, err + } + return changes, activations, nil +} + +// expiryActivations lists the currencies whose expiry these settings turn on, with the +// balances that have no expiry yet and the expiry they get: a full period from now +// (note N4, decided). With apply, it dates those lots. +func (p *LoyaltySettingsProcessor) expiryActivations(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, apply bool) ([]models.LoyaltyExpiryActivation, error) { + activations := []models.LoyaltyExpiryActivation{} + for _, currency := range []string{constants.WalletCurrencyPoint, constants.WalletCurrencyCoin} { + was, will := ExpirySettings(&before, currency), ExpirySettings(&after, currency) + if was.Enabled || !will.Enabled { + continue + } + expiresAt := *ActivationExpiry(will, p.now()) + var lots, amount int64 + var err error + if apply { + lots, amount, err = p.repo.DateUndatedLots(ctx, organizationID, currency, expiresAt) + } else { + lots, amount, err = p.repo.UndatedBalance(ctx, organizationID, currency) + } + if err != nil { + return nil, err + } + activations = append(activations, models.LoyaltyExpiryActivation{Currency: currency, Lots: lots, Amount: amount, ExpiresAt: expiresAt}) + } + return activations, nil } // apply stores every field whose effective value changes and records it. A key left @@ -466,14 +505,15 @@ func diffLoyaltyFields(organizationID uuid.UUID, outletID *uuid.UUID, changedBy } // PreviewOrganization validates new organization settings and lists what saving them -// would change, without writing anything. -func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, organizationID uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) { +// would change, and which balances turning expiry on would date, without writing +// anything. +func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, organizationID uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, []models.LoyaltyExpiryActivation, error) { if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil { - return nil, err + return nil, nil, err } raw, err := p.repo.GetOrganizationValues(ctx, organizationID) if err != nil { - return nil, err + return nil, nil, err } var current models.OrganizationLoyaltySettings loadLoyaltyFields(organizationLoyaltyFields(¤t), raw, "organization "+organizationID.String()) @@ -482,5 +522,9 @@ func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, orga for _, row := range rows { changes = append(changes, loyaltySettingChangeModel(row)) } - return changes, nil + activations, err := p.expiryActivations(ctx, organizationID, current, settings, false) + if err != nil { + return nil, nil, err + } + return changes, activations, nil } diff --git a/internal/processor/loyalty_settings_processor_test.go b/internal/processor/loyalty_settings_processor_test.go index 4ee552c..ecbdb1e 100644 --- a/internal/processor/loyalty_settings_processor_test.go +++ b/internal/processor/loyalty_settings_processor_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -29,6 +30,9 @@ type loyaltyRepoFake struct { orgValues map[string]string changes []entities.LoyaltySettingChange locks []uuid.UUID + // Lots without an expiry per currency: how many and what they hold. + undated map[string][2]int64 + dated map[string]time.Time } func (f *loyaltyRepoFake) GetOutletValues(context.Context, uuid.UUID) (map[string]string, error) { @@ -263,7 +267,7 @@ func TestLoyaltySettings_UpdateOrganization(t *testing.T) { settings.CoinExpiry.Enabled = true settings.CoinExpiry.Unit = constants.LoyaltyExpiryUnitDay - changes, err := p.UpdateOrganization(ctx, org, admin, *settings) + changes, _, err := p.UpdateOrganization(ctx, org, admin, *settings) require.NoError(t, err) keys := []string{} for _, c := range changes { @@ -316,7 +320,7 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) { s, err := p.Organization(ctx, org) require.NoError(t, err) mutate(s) - _, err = p.UpdateOrganization(ctx, org, uuid.New(), *s) + _, _, err = p.UpdateOrganization(ctx, org, uuid.New(), *s) assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name) } assert.Empty(t, repo.changes, "nothing is written when a value is invalid") @@ -330,3 +334,52 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) { _, err = p.UpdateOutlet(ctx, org, uuid.New(), uuid.New(), *s) assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound) } + +func (f *loyaltyRepoFake) UndatedBalance(_ context.Context, _ uuid.UUID, currency string) (int64, int64, error) { + u := f.undated[currency] + return u[0], u[1], nil +} + +func (f *loyaltyRepoFake) DateUndatedLots(_ context.Context, _ uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) { + u := f.undated[currency] + if f.dated == nil { + f.dated = map[string]time.Time{} + } + f.dated[currency] = expiresAt + delete(f.undated, currency) + return u[0], u[1], nil +} + +// Turning expiry on dates the balances that had none, a full cycle away; a dry run +// only counts them, and saving again dates nothing more. +func TestLoyaltySettings_TurningExpiryOnDatesExistingBalances(t *testing.T) { + org, admin := uuid.New(), uuid.New() + repo := &loyaltyRepoFake{undated: map[string][2]int64{"POINT": {3, 1500}, "COIN": {2, 40}}} + p := NewLoyaltySettingsProcessor(repo, txRunnerFake{}) + p.now = func() time.Time { return time.Date(2026, 11, 20, 3, 0, 0, 0, time.UTC) } + ctx := context.Background() + + settings, err := p.Organization(ctx, org) + require.NoError(t, err) + settings.PointExpiry.Enabled = true + + _, preview, err := p.PreviewOrganization(ctx, org, *settings) + require.NoError(t, err) + require.Len(t, preview, 1) + assert.Equal(t, "POINT", preview[0].Currency) + assert.Equal(t, int64(1500), preview[0].Amount) + assert.Empty(t, repo.dated, "a dry run dates nothing") + + _, activations, err := p.UpdateOrganization(ctx, org, admin, *settings) + require.NoError(t, err) + require.Len(t, activations, 1) + assert.Equal(t, int64(3), activations[0].Lots) + // Default FIXED_DATE on 31 Dec: the second one after 20 Nov 2026. + assert.Equal(t, "2027-12-31", activations[0].ExpiresAt.In(walletDisplayLocation).Format("2006-01-02")) + assert.Equal(t, activations[0].ExpiresAt, repo.dated["POINT"]) + assert.NotContains(t, repo.dated, "COIN", "EnakCoin expiry stays off") + + _, again, err := p.UpdateOrganization(ctx, org, admin, *settings) + require.NoError(t, err) + assert.Empty(t, again, "already on: nothing is activated twice") +} diff --git a/internal/processor/point_payment_refund.go b/internal/processor/point_payment_refund.go index e869795..9b6397a 100644 --- a/internal/processor/point_payment_refund.go +++ b/internal/processor/point_payment_refund.go @@ -21,8 +21,8 @@ import ( // // Never more than the payment used, and only what has not come back yet, so it can be // called again safely. Returned EnakPoint go back into lots with the expiry of the lots -// they were taken from (the 7-day extension waits on note N4). It returns how many came -// back in total. +// they were taken from, but at least seven days from the refund (note N4, decided). It +// returns how many came back in total. func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid.UUID) (int64, error) { order, err := p.repo.GetOrder(ctx, orderID, false) if err != nil { @@ -78,7 +78,7 @@ func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid take := min(room, left) left -= take lotID := a.LotID - lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID}) + lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: RefundExpiry(a.ExpiresAt, p.now()), OriginLotID: &lotID}) } toReturn -= left diff --git a/internal/processor/point_refund_db_test.go b/internal/processor/point_refund_db_test.go index f6a5956..55a5e59 100644 --- a/internal/processor/point_refund_db_test.go +++ b/internal/processor/point_refund_db_test.go @@ -92,7 +92,7 @@ func TestPointRefund_UsesTheFrozenValue(t *testing.T) { s, err := settings.Organization(context.Background(), e.org) require.NoError(t, err) s.PointValue = v - _, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s) + _, _, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s) require.NoError(t, err) } setValue(100) diff --git a/internal/processor/wallet_admin_processor.go b/internal/processor/wallet_admin_processor.go index d48c495..3909a35 100644 --- a/internal/processor/wallet_admin_processor.go +++ b/internal/processor/wallet_admin_processor.go @@ -21,15 +21,16 @@ const walletDescriptionLimit = 255 // WalletAdminProcessor serves the dashboard's view of one customer's wallet and the // manual adjustments an admin makes to it (docs/prd-point-coin.md F7). type WalletAdminProcessor struct { - admin repository.WalletAdminRepository - query repository.WalletQueryRepository - wallet *WalletProcessor - tx TxRunner - now func() time.Time + admin repository.WalletAdminRepository + query repository.WalletQueryRepository + wallet *WalletProcessor + settings organizationSettingsReader + tx TxRunner + now func() time.Time } -func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, tx TxRunner) *WalletAdminProcessor { - return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, tx: tx, now: time.Now} +func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, settings organizationSettingsReader, tx TxRunner) *WalletAdminProcessor { + return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, settings: settings, tx: tx, now: time.Now} } // GetCustomerWallet returns the customer's balances, lots and a page of history. @@ -126,13 +127,20 @@ func (p *WalletAdminProcessor) Adjust(ctx context.Context, organizationID, admin if _, err := p.admin.GetCustomer(ctx, organizationID, customerID); err != nil { return err } - var err error - if in.Amount > 0 { - // Adjustments never expire until expiry is decided (F12, note N4). - res, err = p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: entry}) - } else { + if in.Amount < 0 { + var err error res, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: entry}) + return err } + // An adjustment that adds is a new balance, and expires like one (F7, F12). + settings, err := p.settings.Organization(ctx, organizationID) + if err != nil { + return err + } + res, err = p.wallet.Credit(ctx, WalletCreditInput{ + WalletEntry: entry, + Lots: []WalletLotInput{{Amount: entry.Amount, ExpiresAt: ComputeExpiry(ExpirySettings(settings, in.Currency), p.now())}}, + }) return err }) if err != nil { diff --git a/internal/processor/wallet_exchange_processor.go b/internal/processor/wallet_exchange_processor.go index 79beac3..71552a1 100644 --- a/internal/processor/wallet_exchange_processor.go +++ b/internal/processor/wallet_exchange_processor.go @@ -169,7 +169,7 @@ func (p *WalletExchangeProcessor) Exchange(ctx context.Context, customerID uuid. Metadata: metadata, IdempotencyKey: inKey, }, - Lots: exchangeLots(out.Allocations, rate), + Lots: exchangeLots(out.Allocations, rate, ComputeExpiry(settings.PointExpiry, p.now())), }) if err != nil { return err @@ -225,10 +225,9 @@ func exchangePoints(coins int64, rate models.LoyaltyExchangeSettings) int64 { // and after it, which adds up exactly because the total is a multiple of // coin_amount. A lot too small to earn a whole EnakPoint on its own gives none. // -// F4 caps the expiry at now + the EnakPoint validity as well. EnakPoint have no -// validity until the expiry model is decided (F12, note N4), so for now the EnakCoin -// lot's expiry is the only bound. -func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings) []WalletLotInput { +// Each part expires at the sooner of its EnakCoin lot's expiry and pointExpiry, when an +// EnakPoint received now would expire (F4); nil means never. +func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings, pointExpiry *time.Time) []WalletLotInput { var lots []WalletLotInput var coinsSoFar int64 for _, a := range allocations { @@ -239,7 +238,7 @@ func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSet continue } lotID := a.LotID - lots = append(lots, WalletLotInput{Amount: points, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID}) + lots = append(lots, WalletLotInput{Amount: points, ExpiresAt: EarlierExpiry(a.ExpiresAt, pointExpiry), OriginLotID: &lotID}) } return lots } diff --git a/internal/processor/wallet_exchange_processor_test.go b/internal/processor/wallet_exchange_processor_test.go index 83672f1..a6d64c5 100644 --- a/internal/processor/wallet_exchange_processor_test.go +++ b/internal/processor/wallet_exchange_processor_test.go @@ -332,3 +332,23 @@ func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) { assert.ErrorIs(t, err, ErrWalletMoveRejected) assert.Empty(t, e.pins.actions) } + +// F4: EnakPoint from an exchange expire at the sooner of the EnakCoin lot's expiry and +// when EnakPoint received now would expire. +func TestWalletExchange_CappedByThePointExpiry(t *testing.T) { + e := newWalletMoveEnv(t) + e.now = wib(2026, 6, 1, 10, 0) + e.settings.PointExpiry = rolling(30, "DAY", false) + c := e.member("Budi", "081234561234") + soon, later := e.at(24*time.Hour), e.at(90*24*time.Hour) + e.earnCoins(t, c, 10, soon) + e.earnCoins(t, c, 10, later) + e.earnCoins(t, c, 10, nil) + + res, err := e.exchanges().Exchange(e.ctx, c, 30, "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + require.Len(t, res.Lots, 3) + assert.Equal(t, soon, res.Lots[0].ExpiresAt, "the EnakCoin lot expires first") + assert.Equal(t, "2026-07-01", expiryDay(t, res.Lots[1].ExpiresAt), "30 days of EnakPoint come first") + assert.Equal(t, "2026-07-01", expiryDay(t, res.Lots[2].ExpiresAt), "a lot that never expired gets the EnakPoint expiry") +} diff --git a/internal/repository/loyalty_settings_repository.go b/internal/repository/loyalty_settings_repository.go index 4a0f0e0..757634f 100644 --- a/internal/repository/loyalty_settings_repository.go +++ b/internal/repository/loyalty_settings_repository.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/google/uuid" "gorm.io/gorm" @@ -47,6 +48,12 @@ type LoyaltySettingsRepository interface { InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error // ListChanges returns a page of history, newest first, and the total count. ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) + + // UndatedBalance counts an organization's lots of one currency that still hold + // something and have no expiry, and what they hold. + UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (lots, amount int64, err error) + // DateUndatedLots gives those lots an expiry, when expiry is turned on (F12). + DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (lots, amount int64, err error) } type loyaltySettingsRepository struct { @@ -174,3 +181,39 @@ func (r *loyaltySettingsRepository) ListChanges(ctx context.Context, filter Loya } return changes, total, nil } + +func (r *loyaltySettingsRepository) UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (int64, int64, error) { + var row struct{ Lots, Amount int64 } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT COUNT(*) AS lots, COALESCE(SUM(remaining_amount), 0) AS amount + FROM wallet_lots + WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0`, + organizationID, currency).Scan(&row).Error + if err != nil { + return 0, 0, fmt.Errorf("failed to sum undated wallet lots: %w", err) + } + return row.Lots, row.Amount, nil +} + +func (r *loyaltySettingsRepository) DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) { + db, err := r.requireTx(ctx) + if err != nil { + return 0, 0, err + } + var rows []struct{ RemainingAmount int64 } + // Only lots that still hold something: an empty lot never expires anything, and + // leaving it undated keeps the history as it was. + err = db.Raw(` + UPDATE wallet_lots SET expires_at = ? + WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0 + RETURNING remaining_amount`, + expiresAt, organizationID, currency).Scan(&rows).Error + if err != nil { + return 0, 0, fmt.Errorf("failed to date wallet lots: %w", err) + } + var amount int64 + for _, row := range rows { + amount += row.RemainingAmount + } + return int64(len(rows)), amount, nil +} diff --git a/internal/repository/loyalty_settings_repository_test.go b/internal/repository/loyalty_settings_repository_test.go index f8fb7d6..233c66d 100644 --- a/internal/repository/loyalty_settings_repository_test.go +++ b/internal/repository/loyalty_settings_repository_test.go @@ -124,7 +124,7 @@ func TestLoyaltySettings_AgainstPostgres(t *testing.T) { orgSettings.PointValue = 200 saved := make(chan error, 1) go func() { - _, err := p.UpdateOrganization(ctx, org, admin, *orgSettings) + _, _, err := p.UpdateOrganization(ctx, org, admin, *orgSettings) saved <- err }() select { diff --git a/internal/service/loyalty_settings_service.go b/internal/service/loyalty_settings_service.go index a53708c..1d81921 100644 --- a/internal/service/loyalty_settings_service.go +++ b/internal/service/loyalty_settings_service.go @@ -134,7 +134,7 @@ func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context if err != nil { return loyaltyErrorResponse(err) } - view, err := s.organizationView(ctx, apctx.OrganizationID, *current, *current, nil, false) + view, err := s.organizationView(ctx, apctx.OrganizationID, *current, *current, nil, nil, false) if err != nil { return loyaltyErrorResponse(err) } @@ -145,10 +145,11 @@ func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context // of the GET response, which are ignored. type organizationSettingsInput struct { *models.OrganizationLoyaltySettings - Impact json.RawMessage `json:"impact"` - ExpiryPreview json.RawMessage `json:"expiry_preview"` - Changes json.RawMessage `json:"changes"` - DryRun json.RawMessage `json:"dry_run"` + Impact json.RawMessage `json:"impact"` + ExpiryPreview json.RawMessage `json:"expiry_preview"` + ExpiryActivations json.RawMessage `json:"expiry_activations"` + Changes json.RawMessage `json:"changes"` + DryRun json.RawMessage `json:"dry_run"` } func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo, body []byte, dryRun bool) *contract.Response { @@ -166,10 +167,11 @@ func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Cont } var changes []models.LoyaltySettingChange + var activations []models.LoyaltyExpiryActivation if dryRun { - changes, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next) + changes, activations, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next) } else { - changes, err = s.settings.UpdateOrganization(ctx, apctx.OrganizationID, apctx.UserID, next) + changes, activations, err = s.settings.UpdateOrganization(ctx, apctx.OrganizationID, apctx.UserID, next) } if err != nil { return loyaltyErrorResponse(err) @@ -182,7 +184,7 @@ func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Cont } after = *saved } - view, err := s.organizationView(ctx, apctx.OrganizationID, *current, after, changes, dryRun) + view, err := s.organizationView(ctx, apctx.OrganizationID, *current, after, changes, activations, dryRun) if err != nil { return loyaltyErrorResponse(err) } @@ -197,12 +199,15 @@ func (s *LoyaltySettingsServiceImpl) ListHistory(ctx context.Context, apctx *app return contract.BuildSuccessResponse(history) } -func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, changes []models.LoyaltySettingChange, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) { +func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, changes []models.LoyaltySettingChange, activations []models.LoyaltyExpiryActivation, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) { now := time.Now() points, coins, err := s.outstanding.OrganizationOutstanding(ctx, organizationID) if err != nil { return nil, err } + if activations == nil { + activations = []models.LoyaltyExpiryActivation{} + } if changes == nil { changes = []models.LoyaltySettingChange{} } -- 2.54.0 From d293786cdea8cd9add90ab851f1f48f6203333d4 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 14:31:38 +0700 Subject: [PATCH 42/47] feat(loyalty): expire balances whose time is up Adds the expiry job (docs/prd-point-coin.md F12, PC-503). Every 15 minutes it lists the lots whose expiry has passed and that still hold something, the longest overdue first, 500 at a time, and expires each in its own transaction through WalletProcessor.ExpireLot: lock the wallet, read the lot again, and take what is left with an EXPIRE row pointing at the lot, keyed expire:{lot_id}. The description is frozen as "Kedaluwarsa: 130 EnakPoint dari Belanja #ORD-0098", using the amount read under the lock. Lots expire at the end of their day, so none stays past it for more than about a quarter of an hour. It is safe on several instances and across restarts, keeping no state in memory as OmsetMilestoneScheduler does. Selecting the lots FOR UPDATE SKIP LOCKED, as PC-503 suggested, would lock a lot before its wallet and deadlock against payments, which lock the wallet first; instead the listing takes no lock, and the wallet lock plus the idempotency key make a second instance find the lot empty or the key used and take nothing. A lot that fails is logged and retried on the next run without stopping the others. Each customer gets one FCM push per currency with the total that expired ("180 EnakPoint kamu sudah kedaluwarsa.", type WALLET_EXPIRED). Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 10 ++ internal/processor/wallet_expiry_processor.go | 118 ++++++++++++++ .../processor/wallet_expiry_processor_test.go | 152 ++++++++++++++++++ internal/processor/wallet_move_db_test.go | 39 +++++ internal/processor/wallet_processor.go | 49 ++++++ .../repository/wallet_expiry_repository.go | 54 +++++++ internal/service/wallet_expiry_job.go | 67 ++++++++ 7 files changed, 489 insertions(+) create mode 100644 internal/processor/wallet_expiry_processor.go create mode 100644 internal/processor/wallet_expiry_processor_test.go create mode 100644 internal/repository/wallet_expiry_repository.go create mode 100644 internal/service/wallet_expiry_job.go diff --git a/internal/app/app.go b/internal/app/app.go index ee0c19b..934cc47 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -33,6 +33,7 @@ type App struct { omsetScheduler *service.OmsetMilestoneScheduler walletRecon *service.WalletReconciliationJob earningRetry *service.EarningBackfillJob + walletExpiry *service.WalletExpiryJob } func NewApp(db *gorm.DB, redisClient *redis.Client) *App { @@ -63,6 +64,9 @@ func (a *App) Initialize(cfg *config.Config) error { ) // Earns for paid orders whose earning failed at payment time (docs/prd-point-coin.md F3) a.earningRetry = service.NewEarningBackfillJob(processors.earningProcessor) + // Expires balances whose time is up (docs/prd-point-coin.md F12) + a.walletExpiry = service.NewWalletExpiryJob(processor.NewWalletExpiryProcessor( + repository.NewWalletExpiryRepository(a.db), processor.NewWalletProcessor(repos.walletRepo), repos.txManager, processors.customerDeviceProcessor)) services := a.initServices(processors, repos, cfg) validators := a.initValidators() @@ -178,6 +182,9 @@ func (a *App) Start(port string) error { if a.earningRetry != nil { a.earningRetry.Start(30 * time.Minute) } + if a.walletExpiry != nil { + a.walletExpiry.Start(15 * time.Minute) + } engine := a.router.Init() @@ -223,6 +230,9 @@ func (a *App) Shutdown() { if a.earningRetry != nil { a.earningRetry.Stop() } + if a.walletExpiry != nil { + a.walletExpiry.Stop() + } close(a.shutdown) } diff --git a/internal/processor/wallet_expiry_processor.go b/internal/processor/wallet_expiry_processor.go new file mode 100644 index 0000000..c6a4ba7 --- /dev/null +++ b/internal/processor/wallet_expiry_processor.go @@ -0,0 +1,118 @@ +package processor + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/repository" +) + +const ( + // Lots expired per query; a run keeps going until nothing is due. + walletExpiryBatchSize = 500 + // Batches per run at most, so one run cannot run away. + walletExpiryMaxBatches = 40 +) + +// NotificationTypeWalletExpired is the data type of the push a customer gets when +// part of their balance expires. +const NotificationTypeWalletExpired = "WALLET_EXPIRED" + +// WalletExpiryProcessor takes what is left in lots whose expiry has passed +// (docs/prd-point-coin.md F12, PC-503). It is safe to run on several instances at +// once: every lot is expired under its wallet's lock with the key expire:{lot_id}. +type WalletExpiryProcessor struct { + repo repository.WalletExpiryRepository + wallet *WalletProcessor + tx TxRunner + notifier customerNotifier + now func() time.Time +} + +func NewWalletExpiryProcessor(repo repository.WalletExpiryRepository, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletExpiryProcessor { + return &WalletExpiryProcessor{repo: repo, wallet: wallet, tx: tx, notifier: notifier, now: time.Now} +} + +type walletExpiredKey struct { + customerID uuid.UUID + currency string +} + +// ExpireDue expires every lot due now and tells each customer how much of each +// currency they lost, in one push per currency. It returns how many lots it expired. +// A lot that fails is logged and left for the next run; it does not stop the others. +func (p *WalletExpiryProcessor) ExpireDue(ctx context.Context) (int, error) { + asOf := p.now() + expired := map[walletExpiredKey]int64{} + count := 0 + for batch := 0; batch < walletExpiryMaxBatches; batch++ { + due, err := p.repo.ListDueLots(ctx, asOf, walletExpiryBatchSize) + if err != nil { + p.notify(ctx, expired) + return count, err + } + progressed := false + for _, lot := range due { + var res *WalletResult + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + var err error + res, err = p.wallet.ExpireLot(ctx, lot.ID, func(amount int64) string { + return expiryDescription(amount, lot.Currency, lot.SourceDescription) + }, asOf) + return err + }) + if err != nil { + logger.NonContext.Error(fmt.Sprintf("Could not expire wallet lot %s; it will be retried", lot.ID), err) + continue + } + if res == nil || res.Transaction == nil || res.Replayed { + // Another run got there first, or a payment used it up. + continue + } + progressed = true + count++ + expired[walletExpiredKey{lot.CustomerID, lot.Currency}] += -res.Transaction.Amount + } + // A short batch was the last; a batch that moved nothing would only come back + // the same, whether failing or taken by another instance. + if len(due) < walletExpiryBatchSize || !progressed { + break + } + } + p.notify(ctx, expired) + return count, nil +} + +// notify is best effort: the balance has already expired. +func (p *WalletExpiryProcessor) notify(ctx context.Context, expired map[walletExpiredKey]int64) { + if p.notifier == nil { + return + } + for key, amount := range expired { + name := walletCurrencyName(key.currency) + data := map[string]string{ + "type": NotificationTypeWalletExpired, + "currency": key.currency, + "amount": strconv.FormatInt(amount, 10), + } + body := fmt.Sprintf("%d %s kamu sudah kedaluwarsa.", amount, name) + if err := p.notifier.Notify(ctx, key.customerID, name+" kedaluwarsa", body, data); err != nil { + logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about expired %s", key.customerID, name), err) + } + } +} + +// expiryDescription is the EXPIRE row's frozen description (§8.1): +// "Kedaluwarsa: 150 EnakPoint dari Belanja #ORD-0098". +func expiryDescription(amount int64, currency, sourceDescription string) string { + description := fmt.Sprintf("Kedaluwarsa: %d %s", amount, walletCurrencyName(currency)) + if sourceDescription != "" { + description += " dari " + sourceDescription + } + return truncateRunes(description, walletDescriptionLimit) +} diff --git a/internal/processor/wallet_expiry_processor_test.go b/internal/processor/wallet_expiry_processor_test.go new file mode 100644 index 0000000..28b22dd --- /dev/null +++ b/internal/processor/wallet_expiry_processor_test.go @@ -0,0 +1,152 @@ +package processor + +import ( + "context" + "sort" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/repository" +) + +// walletExpiryRepoFake lists the fake wallet's due lots the way the query does, plus +// any extra lots a test wants listed. +type walletExpiryRepoFake struct { + wallet *walletRepoFake + extra []repository.DueLot +} + +func (f *walletExpiryRepoFake) ListDueLots(_ context.Context, asOf time.Time, limit int) ([]repository.DueLot, error) { + descriptions := map[uuid.UUID]string{} + for _, tx := range f.wallet.transactions { + descriptions[tx.ID] = tx.Description + } + due := append([]repository.DueLot(nil), f.extra...) + for _, lot := range f.wallet.lots { + if lot.RemainingAmount > 0 && lot.ExpiresAt != nil && !lot.ExpiresAt.After(asOf) { + due = append(due, repository.DueLot{ + ID: lot.ID, CustomerID: lot.CustomerID, Currency: lot.Currency, Remaining: lot.RemainingAmount, + ExpiresAt: *lot.ExpiresAt, SourceDescription: descriptions[lot.SourceTransactionID], + }) + } + } + sort.SliceStable(due, func(i, j int) bool { return due[i].ExpiresAt.Before(due[j].ExpiresAt) }) + if len(due) > limit { + due = due[:limit] + } + return due, nil +} + +func (e *walletMoveEnv) expiry(notifier customerNotifier) (*WalletExpiryProcessor, *walletExpiryRepoFake) { + repo := &walletExpiryRepoFake{wallet: e.repo} + p := NewWalletExpiryProcessor(repo, e.p, txRunnerFake{}, notifier) + p.now = func() time.Time { return e.now } + return p, repo +} + +func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + ord := earn(a, 150, e.at(-time.Hour)) + ord.Description = "Belanja #ORD-0098" + due := e.credit(t, ord) + e.credit(t, earn(a, 50, e.at(-2*time.Hour))) + e.credit(t, earn(a, 70, e.at(time.Hour))) // not yet + e.credit(t, earn(a, 30, nil)) // never + e.earnCoins(t, a, 4, e.at(-time.Minute)) + // A payment already used part of the first lot; only the rest expires. + _, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: pay(a, 20).WalletEntry, PreferredLotIDs: []uuid.UUID{due.Lots[0].ID}}) + require.NoError(t, err) + + notifier := ¬ifierFake{} + p, _ := e.expiry(notifier) + count, err := p.ExpireDue(e.ctx) + require.NoError(t, err) + + assert.Equal(t, 3, count) + assert.Equal(t, int64(100), e.balance(t, a), "70 not due yet + 30 that never expires") + assert.Equal(t, int64(0), e.coinBalance(t, a)) + + var expire *entities.WalletTransaction + for _, tx := range e.repo.transactions { + if tx.Type == constants.WalletTxTypeExpire && tx.ReferenceID == due.Lots[0].ID { + expire = tx + } + } + require.NotNil(t, expire) + assert.Equal(t, int64(-130), expire.Amount) + assert.Equal(t, "Kedaluwarsa: 130 EnakPoint dari Belanja #ORD-0098", expire.Description) + assert.Equal(t, constants.WalletRefTypeLot, expire.ReferenceType) + assert.Equal(t, "expire:"+due.Lots[0].ID.String(), *expire.IdempotencyKey) + + // One push per currency, with the total. + pushes := notifier.pushes[a] + require.Len(t, pushes, 2) + byCurrency := map[string]pushFake{} + for _, p := range pushes { + byCurrency[p.data["currency"]] = p + } + assert.Equal(t, "EnakPoint kedaluwarsa", byCurrency["POINT"].title) + assert.Equal(t, "180 EnakPoint kamu sudah kedaluwarsa.", byCurrency["POINT"].body) + assert.Equal(t, NotificationTypeWalletExpired, byCurrency["POINT"].data["type"]) + assert.Equal(t, "4", byCurrency["COIN"].data["amount"]) +} + +func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + e.credit(t, earn(a, 150, e.at(-time.Hour))) + notifier := ¬ifierFake{} + + // Two instances, one after the other. + first, _ := e.expiry(notifier) + second, _ := e.expiry(notifier) + n1, err := first.ExpireDue(e.ctx) + require.NoError(t, err) + n2, err := second.ExpireDue(e.ctx) + require.NoError(t, err) + + assert.Equal(t, 1, n1) + assert.Equal(t, 0, n2) + assert.Len(t, notifier.pushes[a], 1) + var expires int + for _, tx := range e.repo.transactions { + if tx.Type == constants.WalletTxTypeExpire { + expires++ + } + } + assert.Equal(t, 1, expires) +} + +func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + e.credit(t, earn(a, 150, e.at(-time.Hour))) + p, repo := e.expiry(nil) + // A lot listed that ExpireLot cannot find. + repo.extra = []repository.DueLot{{ID: uuid.New(), CustomerID: a, Currency: "POINT", Remaining: 5, ExpiresAt: e.now.Add(-3 * time.Hour)}} + + count, err := p.ExpireDue(e.ctx) + require.NoError(t, err) + assert.Equal(t, 1, count) + assert.Equal(t, int64(0), e.balance(t, a)) +} + +func TestWalletExpiry_NothingDue(t *testing.T) { + e := newWalletMoveEnv(t) + a := e.member("Anita", "081200005678") + e.credit(t, earn(a, 150, e.at(time.Hour))) + notifier := ¬ifierFake{} + p, _ := e.expiry(notifier) + + count, err := p.ExpireDue(e.ctx) + require.NoError(t, err) + assert.Zero(t, count) + assert.Empty(t, notifier.pushes) +} diff --git a/internal/processor/wallet_move_db_test.go b/internal/processor/wallet_move_db_test.go index 0f654d5..d425cda 100644 --- a/internal/processor/wallet_move_db_test.go +++ b/internal/processor/wallet_move_db_test.go @@ -196,3 +196,42 @@ func TestWalletTrace_AgainstPostgres(t *testing.T) { _, err = NewWalletTraceProcessor(repository.NewWalletTraceRepository(db)).Trace(context.Background(), uuid.New(), payment.Transaction.ID) assert.ErrorIs(t, err, repository.ErrWalletTransactionNotFound) } + +// Two instances of the expiry job at once expire each lot exactly once (PC-503). +func TestWalletExpiry_TwoInstancesAgainstPostgres(t *testing.T) { + db, _, a, b := walletMoveDB(t) + wallet := NewWalletProcessor(repository.NewWalletRepository(db)) + txm := repository.NewTxManager(db) + past := time.Now().Add(-time.Hour) + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + for i := 0; i < 5; i++ { + for _, c := range []uuid.UUID{a, b} { + if _, err := wallet.Credit(ctx, earn(c, 10, &past)); err != nil { + return err + } + } + } + return nil + })) + + var wg sync.WaitGroup + counts := make([]int, 2) + for i := range counts { + wg.Add(1) + go func(i int) { + defer wg.Done() + p := NewWalletExpiryProcessor(repository.NewWalletExpiryRepository(db), wallet, txm, nil) + n, err := p.ExpireDue(context.Background()) + assert.NoError(t, err) + counts[i] = n + }(i) + } + wg.Wait() + assert.Equal(t, 10, counts[0]+counts[1], "every lot once, between them") + + var expires, left int64 + require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id IN ? AND type = 'EXPIRE'`, []uuid.UUID{a, b}).Scan(&expires).Error) + require.NoError(t, db.Raw(`SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets WHERE customer_id IN ?`, []uuid.UUID{a, b}).Scan(&left).Error) + assert.Equal(t, int64(10), expires) + assert.Zero(t, left) +} diff --git a/internal/processor/wallet_processor.go b/internal/processor/wallet_processor.go index 28a22a2..17bb8de 100644 --- a/internal/processor/wallet_processor.go +++ b/internal/processor/wallet_processor.go @@ -149,6 +149,55 @@ func (p *WalletProcessor) FindTransaction(ctx context.Context, idempotencyKey st return p.repo.GetTransactionByIdempotencyKey(ctx, idempotencyKey) } +// ExpireLot takes what is left in a lot whose expiry has passed at asOf, as an EXPIRE +// row pointing at the lot (F12). It locks the wallet before reading the lot again, so +// it never races a payment for the same balance, and the key expire:{lot_id} makes a +// second run, on this instance or another, take nothing more. describe gives the +// row's description for the amount taken. It returns nil when +// there is nothing to take: the lot is empty, not due, or already expired. +func (p *WalletProcessor) ExpireLot(ctx context.Context, lotID uuid.UUID, describe func(amount int64) string, asOf time.Time) (*WalletResult, error) { + lot, err := p.getLot(ctx, lotID) + if err != nil { + return nil, err + } + if _, err := p.repo.LockWallet(ctx, lot.CustomerID); err != nil { + return nil, err + } + // Read again under the lock: a payment may have used it up meanwhile. + if lot, err = p.getLot(ctx, lotID); err != nil { + return nil, err + } + if lot.RemainingAmount == 0 || lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf) { + return nil, nil + } + return p.Debit(ctx, WalletDebitInput{ + WalletEntry: WalletEntry{ + CustomerID: lot.CustomerID, + Currency: lot.Currency, + Type: constants.WalletTxTypeExpire, + Amount: lot.RemainingAmount, + ReferenceType: constants.WalletRefTypeLot, + ReferenceID: lot.ID, + Description: describe(lot.RemainingAmount), + Metadata: entities.Metadata{"expires_at": lot.ExpiresAt.UTC().Format(time.RFC3339)}, + IdempotencyKey: "expire:" + lot.ID.String(), + }, + // Exactly what the lot holds, from the lot itself, even though it has expired. + PreferredLotIDs: []uuid.UUID{lot.ID}, + }) +} + +func (p *WalletProcessor) getLot(ctx context.Context, lotID uuid.UUID) (*entities.WalletLot, error) { + lots, err := p.repo.GetLotsByIDs(ctx, []uuid.UUID{lotID}) + if err != nil { + return nil, err + } + if len(lots) == 0 { + return nil, fmt.Errorf("%w: lot %s does not exist", ErrWalletInvalidEntry, lotID) + } + return &lots[0], nil +} + // Credit adds Amount to the wallet and creates its lots. func (p *WalletProcessor) Credit(ctx context.Context, in WalletCreditInput) (*WalletResult, error) { if err := validateWalletEntry(&in.WalletEntry, true); err != nil { diff --git a/internal/repository/wallet_expiry_repository.go b/internal/repository/wallet_expiry_repository.go new file mode 100644 index 0000000..a4ed31e --- /dev/null +++ b/internal/repository/wallet_expiry_repository.go @@ -0,0 +1,54 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// DueLot is a lot whose expiry has passed and that still holds something. +type DueLot struct { + ID uuid.UUID + CustomerID uuid.UUID + Currency string + Remaining int64 + ExpiresAt time.Time + // The description of the row that created the lot, for the EXPIRE row's. + SourceDescription string +} + +// WalletExpiryRepository finds what the expiry job has to do (docs/prd-point-coin.md +// F12). Balances only change through WalletProcessor. +type WalletExpiryRepository interface { + // ListDueLots returns up to limit lots due at asOf, the longest overdue first. It + // takes no lock: locking a lot before its wallet would deadlock against payments, + // which lock the wallet first. WalletProcessor.ExpireLot locks and reads again. + ListDueLots(ctx context.Context, asOf time.Time, limit int) ([]DueLot, error) +} + +type walletExpiryRepository struct { + db *gorm.DB +} + +func NewWalletExpiryRepository(db *gorm.DB) WalletExpiryRepository { + return &walletExpiryRepository{db: db} +} + +func (r *walletExpiryRepository) ListDueLots(ctx context.Context, asOf time.Time, limit int) ([]DueLot, error) { + var lots []DueLot + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT l.id, l.customer_id, l.currency, l.remaining_amount AS remaining, l.expires_at, + t.description AS source_description + FROM wallet_lots l + JOIN wallet_transactions t ON t.id = l.source_transaction_id + WHERE l.remaining_amount > 0 AND l.expires_at <= ? + ORDER BY l.expires_at, l.id + LIMIT ?`, asOf, limit).Scan(&lots).Error + if err != nil { + return nil, fmt.Errorf("failed to list due wallet lots: %w", err) + } + return lots, nil +} diff --git a/internal/service/wallet_expiry_job.go b/internal/service/wallet_expiry_job.go new file mode 100644 index 0000000..d671d15 --- /dev/null +++ b/internal/service/wallet_expiry_job.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + "sync" + "time" + + "apskel-pos-be/internal/logger" +) + +// Lots expire at the end of their day, so running every quarter of an hour keeps any +// lot from staying past its expiry for more than about that long (PC-503). +const defaultWalletExpiryInterval = 15 * time.Minute + +type dueExpirer interface { + ExpireDue(ctx context.Context) (int, error) +} + +// WalletExpiryJob expires the balances whose time is up (docs/prd-point-coin.md F12). +// Unlike OmsetMilestoneScheduler it keeps no state in memory: several instances can +// run it at once, and a restart repeats nothing, because every lot is expired under +// its wallet's lock with an idempotency key. +type WalletExpiryJob struct { + expirer dueExpirer + stopCh chan struct{} + stopOnce sync.Once +} + +func NewWalletExpiryJob(expirer dueExpirer) *WalletExpiryJob { + return &WalletExpiryJob{expirer: expirer, stopCh: make(chan struct{})} +} + +func (j *WalletExpiryJob) Start(interval time.Duration) { + if interval <= 0 { + interval = defaultWalletExpiryInterval + } + go func() { + j.RunOnce(context.Background()) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + j.RunOnce(context.Background()) + case <-j.stopCh: + return + } + } + }() + logger.NonContext.Infof("Wallet expiry job started (interval: %s)", interval) +} + +func (j *WalletExpiryJob) Stop() { + j.stopOnce.Do(func() { close(j.stopCh) }) +} + +// RunOnce expires what is due and returns how many lots it expired. +func (j *WalletExpiryJob) RunOnce(ctx context.Context) int { + expired, err := j.expirer.ExpireDue(ctx) + if err != nil { + logger.NonContext.Error("Wallet expiry failed to run", err) + } + if expired > 0 { + logger.NonContext.Infof("Wallet expiry expired %d lots", expired) + } + return expired +} -- 2.54.0 From 550122f29c44989f19e5bf50c4440d0ec420c9a1 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 14:35:12 +0700 Subject: [PATCH 43/47] feat(loyalty): remind customers before balances expire Adds what the customer sees of expiry (docs/prd-point-coin.md F6, F12, PC-504). GET /customer/wallet/expiring lists everything that will expire, per currency and day, soonest first. GET /customer/wallet already had the nearest expiry per currency. The expiry job now also sends reminders, with the settings of note N4 as decided: once, reminder_days before (7 by default, 0 for none), per currency. A customer gets one FCM push per currency and expiry day, however many lots make it up: "150 EnakPoint akan kedaluwarsa pada 31 Okt 2026. Pakai sebelum hangus.", with type WALLET_EXPIRING, the currency, amount and expiry_date in its data. Reminders cover whatever falls within the window, so a run that was missed catches up rather than skipping a day. Migration 000097 adds wallet_expiry_reminders, one row per customer, currency and expiry day. The row is written before the push is sent, so several instances of the job or a restart never remind twice; a push that then fails is logged and not retried. Lots that expire later on the same day as an earlier reminder are not reminded of again. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 4 +- internal/handler/customer_points_handler.go | 23 +++++ internal/models/wallet.go | 7 ++ .../processor/customer_points_processor.go | 8 ++ internal/processor/wallet_expiry_processor.go | 82 +++++++++++++++- .../processor/wallet_expiry_processor_test.go | 94 ++++++++++++++++++- internal/processor/wallet_move_db_test.go | 2 +- internal/processor/wallet_query_processor.go | 19 ++++ .../processor/wallet_query_processor_test.go | 21 +++++ .../repository/wallet_expiry_repository.go | 64 +++++++++++++ .../repository/wallet_query_repository.go | 19 ++++ internal/router/router.go | 1 + internal/router/router_test.go | 1 + internal/service/customer_points_service.go | 8 ++ internal/service/wallet_expiry_job.go | 20 +++- ...97_create_wallet_expiry_reminders.down.sql | 1 + ...0097_create_wallet_expiry_reminders.up.sql | 11 +++ 17 files changed, 374 insertions(+), 11 deletions(-) create mode 100644 migrations/000097_create_wallet_expiry_reminders.down.sql create mode 100644 migrations/000097_create_wallet_expiry_reminders.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index 934cc47..df1bf1b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -64,9 +64,9 @@ func (a *App) Initialize(cfg *config.Config) error { ) // Earns for paid orders whose earning failed at payment time (docs/prd-point-coin.md F3) a.earningRetry = service.NewEarningBackfillJob(processors.earningProcessor) - // Expires balances whose time is up (docs/prd-point-coin.md F12) + // Expires balances whose time is up and reminds customers before (docs/prd-point-coin.md F12) a.walletExpiry = service.NewWalletExpiryJob(processor.NewWalletExpiryProcessor( - repository.NewWalletExpiryRepository(a.db), processor.NewWalletProcessor(repos.walletRepo), repos.txManager, processors.customerDeviceProcessor)) + repository.NewWalletExpiryRepository(a.db), processors.loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, processors.customerDeviceProcessor)) services := a.initServices(processors, repos, cfg) validators := a.initValidators() diff --git a/internal/handler/customer_points_handler.go b/internal/handler/customer_points_handler.go index 818d914..0df8ff2 100644 --- a/internal/handler/customer_points_handler.go +++ b/internal/handler/customer_points_handler.go @@ -204,3 +204,26 @@ func walletErrorCode(err error) string { return constants.InternalServerErrorCode } } + +// GetCustomerWalletExpiring is GET /customer/wallet/expiring: what will expire, per +// currency and day (docs/prd-point-coin.md F6). +func (h *CustomerPointsHandler) GetCustomerWalletExpiring(c *gin.Context) { + ctx := c.Request.Context() + customerID, ok := c.Get("customer_id") + customerIDStr, isString := customerID.(string) + if !ok || !isString { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"), + }), "CustomerPointsHandler::GetCustomerWalletExpiring") + return + } + response, err := h.customerPointsService.GetCustomerWalletExpiring(ctx, customerIDStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWalletExpiring -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()), + }), "CustomerPointsHandler::GetCustomerWalletExpiring") + return + } + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWalletExpiring") +} diff --git a/internal/models/wallet.go b/internal/models/wallet.go index da38e30..9412444 100644 --- a/internal/models/wallet.go +++ b/internal/models/wallet.go @@ -157,3 +157,10 @@ type PointPaymentPreview struct { // Rupiah covered by MaxPoints. MaxAmount int64 `json:"max_amount"` } + +// CustomerWalletExpiringList is GET /customer/wallet/expiring (docs/prd-point-coin.md +// F6): everything that will expire, per currency and day, soonest first. +type CustomerWalletExpiringList struct { + Point []CustomerWalletExpiring `json:"point"` + Coin []CustomerWalletExpiring `json:"coin"` +} diff --git a/internal/processor/customer_points_processor.go b/internal/processor/customer_points_processor.go index 020c705..bd87c33 100644 --- a/internal/processor/customer_points_processor.go +++ b/internal/processor/customer_points_processor.go @@ -224,3 +224,11 @@ func (p *CustomerPointsProcessor) GetFerrisWheelGameAPI(ctx context.Context) (*m }, }, nil } + +func (p *CustomerPointsProcessor) GetCustomerWalletExpiringAPI(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) { + id, err := parseWalletCustomerID(customerID) + if err != nil { + return nil, err + } + return p.walletQuery.Expiring(ctx, id) +} diff --git a/internal/processor/wallet_expiry_processor.go b/internal/processor/wallet_expiry_processor.go index c6a4ba7..b322362 100644 --- a/internal/processor/wallet_expiry_processor.go +++ b/internal/processor/wallet_expiry_processor.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/logger" "apskel-pos-be/internal/repository" ) @@ -23,19 +24,24 @@ const ( // part of their balance expires. const NotificationTypeWalletExpired = "WALLET_EXPIRED" +// NotificationTypeWalletExpiring is the data type of the reminder a customer gets +// before part of their balance expires. +const NotificationTypeWalletExpiring = "WALLET_EXPIRING" + // WalletExpiryProcessor takes what is left in lots whose expiry has passed // (docs/prd-point-coin.md F12, PC-503). It is safe to run on several instances at // once: every lot is expired under its wallet's lock with the key expire:{lot_id}. type WalletExpiryProcessor struct { repo repository.WalletExpiryRepository + settings organizationSettingsReader wallet *WalletProcessor tx TxRunner notifier customerNotifier now func() time.Time } -func NewWalletExpiryProcessor(repo repository.WalletExpiryRepository, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletExpiryProcessor { - return &WalletExpiryProcessor{repo: repo, wallet: wallet, tx: tx, notifier: notifier, now: time.Now} +func NewWalletExpiryProcessor(repo repository.WalletExpiryRepository, settings organizationSettingsReader, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletExpiryProcessor { + return &WalletExpiryProcessor{repo: repo, settings: settings, wallet: wallet, tx: tx, notifier: notifier, now: time.Now} } type walletExpiredKey struct { @@ -116,3 +122,75 @@ func expiryDescription(amount int64, currency, sourceDescription string) string } return truncateRunes(description, walletDescriptionLimit) } + +// SendReminders tells customers, reminder_days before, how much of their balance +// expires on a day (F12): one push per customer, currency and expiry day, however many +// lots make it up. A reminder is recorded before it is sent, so another instance or a +// later run never sends it again; a push that then fails is logged and not retried. +// It returns how many reminders it sent. +func (p *WalletExpiryProcessor) SendReminders(ctx context.Context) (int, error) { + now := p.now() + organizations, err := p.repo.OrganizationsWithUpcomingExpiry(ctx, now) + if err != nil { + return 0, err + } + sent := 0 + for _, organizationID := range organizations { + settings, err := p.settings.Organization(ctx, organizationID) + if err != nil { + logger.NonContext.Error(fmt.Sprintf("Could not read the expiry settings of organization %s; its reminders wait for the next run", organizationID), err) + continue + } + for _, currency := range []string{constants.WalletCurrencyPoint, constants.WalletCurrencyCoin} { + days := ExpirySettings(settings, currency).ReminderDays + if days <= 0 { + continue + } + until := endOfWalletDay(walletDay(now).AddDate(0, 0, int(days))) + upcoming, err := p.repo.UpcomingUnreminded(ctx, organizationID, currency, now, *until) + if err != nil { + return sent, err + } + for _, u := range upcoming { + first, err := p.repo.MarkReminded(ctx, u, currency) + if err != nil { + return sent, err + } + if !first { + continue + } + sent++ + p.remind(ctx, u, currency) + } + } + } + return sent, nil +} + +func (p *WalletExpiryProcessor) remind(ctx context.Context, u repository.UpcomingExpiry, currency string) { + if p.notifier == nil { + return + } + name := walletCurrencyName(currency) + body := fmt.Sprintf("%d %s akan kedaluwarsa pada %s. Pakai sebelum hangus.", u.Amount, name, formatWalletDate(u.Date)) + data := map[string]string{ + "type": NotificationTypeWalletExpiring, + "currency": currency, + "amount": strconv.FormatInt(u.Amount, 10), + "expiry_date": u.Date, + } + if err := p.notifier.Notify(ctx, u.CustomerID, name+" akan kedaluwarsa", body, data); err != nil { + logger.NonContext.Error(fmt.Sprintf("Could not remind customer %s of expiring %s", u.CustomerID, name), err) + } +} + +var walletMonthNames = [...]string{"Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"} + +// formatWalletDate writes a YYYY-MM-DD date the way the apps do: "31 Okt 2026". +func formatWalletDate(date string) string { + d, err := time.Parse("2006-01-02", date) + if err != nil { + return date + } + return fmt.Sprintf("%d %s %d", d.Day(), walletMonthNames[d.Month()-1], d.Year()) +} diff --git a/internal/processor/wallet_expiry_processor_test.go b/internal/processor/wallet_expiry_processor_test.go index 28b22dd..d5bbbe8 100644 --- a/internal/processor/wallet_expiry_processor_test.go +++ b/internal/processor/wallet_expiry_processor_test.go @@ -20,6 +20,8 @@ import ( type walletExpiryRepoFake struct { wallet *walletRepoFake extra []repository.DueLot + // customer/currency/date of the reminders recorded. + reminded map[string]bool } func (f *walletExpiryRepoFake) ListDueLots(_ context.Context, asOf time.Time, limit int) ([]repository.DueLot, error) { @@ -45,7 +47,7 @@ func (f *walletExpiryRepoFake) ListDueLots(_ context.Context, asOf time.Time, li func (e *walletMoveEnv) expiry(notifier customerNotifier) (*WalletExpiryProcessor, *walletExpiryRepoFake) { repo := &walletExpiryRepoFake{wallet: e.repo} - p := NewWalletExpiryProcessor(repo, e.p, txRunnerFake{}, notifier) + p := NewWalletExpiryProcessor(repo, e, e.p, txRunnerFake{}, notifier) p.now = func() time.Time { return e.now } return p, repo } @@ -150,3 +152,93 @@ func TestWalletExpiry_NothingDue(t *testing.T) { assert.Zero(t, count) assert.Empty(t, notifier.pushes) } + +func (f *walletExpiryRepoFake) OrganizationsWithUpcomingExpiry(_ context.Context, asOf time.Time) ([]uuid.UUID, error) { + seen := map[uuid.UUID]bool{} + var out []uuid.UUID + for _, lot := range f.wallet.lots { + if lot.RemainingAmount > 0 && lot.ExpiresAt != nil && lot.ExpiresAt.After(asOf) && !seen[lot.OrganizationID] { + seen[lot.OrganizationID] = true + out = append(out, lot.OrganizationID) + } + } + return out, nil +} + +func (f *walletExpiryRepoFake) UpcomingUnreminded(_ context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]repository.UpcomingExpiry, error) { + sums := map[[2]string]int64{} + var order [][2]string + for _, lot := range f.wallet.lots { + if lot.OrganizationID != organizationID || lot.Currency != currency || lot.RemainingAmount == 0 || + lot.ExpiresAt == nil || !lot.ExpiresAt.After(asOf) || lot.ExpiresAt.After(until) { + continue + } + key := [2]string{lot.CustomerID.String(), lot.ExpiresAt.In(walletDisplayLocation).Format("2006-01-02")} + if f.reminded[key[0]+"/"+currency+"/"+key[1]] { + continue + } + if _, ok := sums[key]; !ok { + order = append(order, key) + } + sums[key] += lot.RemainingAmount + } + var out []repository.UpcomingExpiry + for _, key := range order { + out = append(out, repository.UpcomingExpiry{CustomerID: uuid.MustParse(key[0]), Date: key[1], Amount: sums[key]}) + } + return out, nil +} + +func (f *walletExpiryRepoFake) MarkReminded(_ context.Context, u repository.UpcomingExpiry, currency string) (bool, error) { + if f.reminded == nil { + f.reminded = map[string]bool{} + } + key := u.CustomerID.String() + "/" + currency + "/" + u.Date + if f.reminded[key] { + return false, nil + } + f.reminded[key] = true + return true, nil +} + +func TestWalletExpiry_RemindsOncePerDayBeforeExpiry(t *testing.T) { + e := newWalletMoveEnv(t) + e.now = wib(2026, 10, 25, 9, 0) + e.settings.PointExpiry.ReminderDays = 7 + e.settings.CoinExpiry.ReminderDays = 0 // no reminders for EnakCoin + a := e.member("Anita", "081200005678") + oct31 := wib(2026, 10, 31, 23, 59) + nov30 := wib(2026, 11, 30, 23, 59) + e.credit(t, earn(a, 100, &oct31)) + e.credit(t, earn(a, 50, &oct31)) + e.credit(t, earn(a, 70, &nov30)) // too far off yet + e.earnCoins(t, a, 5, &oct31) + notifier := ¬ifierFake{} + p, _ := e.expiry(notifier) + + sent, err := p.SendReminders(e.ctx) + require.NoError(t, err) + assert.Equal(t, 1, sent) + require.Len(t, notifier.pushes[a], 1) + push := notifier.pushes[a][0] + assert.Equal(t, "EnakPoint akan kedaluwarsa", push.title) + assert.Equal(t, "150 EnakPoint akan kedaluwarsa pada 31 Okt 2026. Pakai sebelum hangus.", push.body) + assert.Equal(t, map[string]string{"type": NotificationTypeWalletExpiring, "currency": "POINT", "amount": "150", "expiry_date": "2026-10-31"}, push.data) + + // The next run, on this instance or another, sends nothing again. + again, err := p.SendReminders(e.ctx) + require.NoError(t, err) + assert.Zero(t, again) + + // Once 30 Nov comes within seven days, it gets its own reminder. + e.now = wib(2026, 11, 23, 9, 0) + sent, err = p.SendReminders(e.ctx) + require.NoError(t, err) + assert.Equal(t, 1, sent) + assert.Equal(t, "70", notifier.pushes[a][1].data["amount"]) +} + +func TestFormatWalletDate(t *testing.T) { + assert.Equal(t, "31 Okt 2026", formatWalletDate("2026-10-31")) + assert.Equal(t, "1 Mei 2027", formatWalletDate("2027-05-01")) +} diff --git a/internal/processor/wallet_move_db_test.go b/internal/processor/wallet_move_db_test.go index d425cda..0808d2f 100644 --- a/internal/processor/wallet_move_db_test.go +++ b/internal/processor/wallet_move_db_test.go @@ -220,7 +220,7 @@ func TestWalletExpiry_TwoInstancesAgainstPostgres(t *testing.T) { wg.Add(1) go func(i int) { defer wg.Done() - p := NewWalletExpiryProcessor(repository.NewWalletExpiryRepository(db), wallet, txm, nil) + p := NewWalletExpiryProcessor(repository.NewWalletExpiryRepository(db), fixedOrganizationSettings{}, wallet, txm, nil) n, err := p.ExpireDue(context.Background()) assert.NoError(t, err) counts[i] = n diff --git a/internal/processor/wallet_query_processor.go b/internal/processor/wallet_query_processor.go index 02ae40d..3a83fb6 100644 --- a/internal/processor/wallet_query_processor.go +++ b/internal/processor/wallet_query_processor.go @@ -315,3 +315,22 @@ func walletTransactionFilter(customerID uuid.UUID, q models.ListCustomerWalletTr } return filter, page, nil } + +// Expiring is GET /customer/wallet/expiring: what will expire, grouped by day (F6). +func (p *WalletQueryProcessor) Expiring(ctx context.Context, customerID uuid.UUID) (*models.CustomerWalletExpiringList, error) { + rows, err := p.repo.ExpiringByDay(ctx, customerID, p.now()) + if err != nil { + return nil, err + } + list := &models.CustomerWalletExpiringList{Point: []models.CustomerWalletExpiring{}, Coin: []models.CustomerWalletExpiring{}} + for _, row := range rows { + item := models.CustomerWalletExpiring{Amount: row.Amount, Date: row.Date} + switch row.Currency { + case constants.WalletCurrencyPoint: + list.Point = append(list.Point, item) + case constants.WalletCurrencyCoin: + list.Coin = append(list.Coin, item) + } + } + return list, nil +} diff --git a/internal/processor/wallet_query_processor_test.go b/internal/processor/wallet_query_processor_test.go index d990a2b..bf6def7 100644 --- a/internal/processor/wallet_query_processor_test.go +++ b/internal/processor/wallet_query_processor_test.go @@ -243,3 +243,24 @@ func TestWalletQueryProcessor_RejectsBadQueries(t *testing.T) { func (f *walletQueryRepoFake) OrganizationOutstanding(context.Context, uuid.UUID) (int64, int64, error) { return 0, 0, nil } + +func (f *walletQueryRepoFake) ExpiringByDay(context.Context, uuid.UUID, time.Time) ([]repository.WalletExpiringAmount, error) { + return f.expiring, nil +} + +func TestWalletQueryProcessor_ExpiringGroupsByCurrencyAndDay(t *testing.T) { + repo := &walletQueryRepoFake{org: uuid.New(), expiring: []repository.WalletExpiringAmount{ + {Currency: "POINT", Date: "2026-10-31", Amount: 150}, + {Currency: "COIN", Date: "2026-10-31", Amount: 4}, + {Currency: "POINT", Date: "2026-12-31", Amount: 200}, + }} + got, err := newWalletQueryTest(repo, nil).Expiring(context.Background(), uuid.New()) + require.NoError(t, err) + assert.Equal(t, []models.CustomerWalletExpiring{{Amount: 150, Date: "2026-10-31"}, {Amount: 200, Date: "2026-12-31"}}, got.Point) + assert.Equal(t, []models.CustomerWalletExpiring{{Amount: 4, Date: "2026-10-31"}}, got.Coin) + + empty, err := newWalletQueryTest(&walletQueryRepoFake{org: uuid.New()}, nil).Expiring(context.Background(), uuid.New()) + require.NoError(t, err) + assert.NotNil(t, empty.Point, "an empty list, not null") + assert.NotNil(t, empty.Coin) +} diff --git a/internal/repository/wallet_expiry_repository.go b/internal/repository/wallet_expiry_repository.go index a4ed31e..c7e0b99 100644 --- a/internal/repository/wallet_expiry_repository.go +++ b/internal/repository/wallet_expiry_repository.go @@ -20,6 +20,14 @@ type DueLot struct { SourceDescription string } +// UpcomingExpiry is how much of a customer's balance expires on one day. +type UpcomingExpiry struct { + CustomerID uuid.UUID + // A calendar date in walletDisplayTimeZone, formatted YYYY-MM-DD. + Date string + Amount int64 +} + // WalletExpiryRepository finds what the expiry job has to do (docs/prd-point-coin.md // F12). Balances only change through WalletProcessor. type WalletExpiryRepository interface { @@ -27,6 +35,17 @@ type WalletExpiryRepository interface { // takes no lock: locking a lot before its wallet would deadlock against payments, // which lock the wallet first. WalletProcessor.ExpireLot locks and reads again. ListDueLots(ctx context.Context, asOf time.Time, limit int) ([]DueLot, error) + + // OrganizationsWithUpcomingExpiry lists the organizations that have balance + // expiring after asOf. + OrganizationsWithUpcomingExpiry(ctx context.Context, asOf time.Time) ([]uuid.UUID, error) + // UpcomingUnreminded sums, per customer and expiry day, the balance of one currency + // of an organization expiring after asOf and up to until, leaving out the days the + // customer has already been reminded of. + UpcomingUnreminded(ctx context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]UpcomingExpiry, error) + // MarkReminded records a reminder, and reports false when it was already recorded, + // by this run or another. + MarkReminded(ctx context.Context, reminder UpcomingExpiry, currency string) (bool, error) } type walletExpiryRepository struct { @@ -52,3 +71,48 @@ func (r *walletExpiryRepository) ListDueLots(ctx context.Context, asOf time.Time } return lots, nil } + +func (r *walletExpiryRepository) OrganizationsWithUpcomingExpiry(ctx context.Context, asOf time.Time) ([]uuid.UUID, error) { + var ids []uuid.UUID + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT DISTINCT organization_id FROM wallet_lots + WHERE remaining_amount > 0 AND expires_at > ?`, asOf).Scan(&ids).Error + if err != nil { + return nil, fmt.Errorf("failed to list organizations with expiring balances: %w", err) + } + return ids, nil +} + +func (r *walletExpiryRepository) UpcomingUnreminded(ctx context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]UpcomingExpiry, error) { + var rows []UpcomingExpiry + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + WITH by_day AS ( + SELECT customer_id, (expires_at AT TIME ZONE ?)::date AS day, SUM(remaining_amount) AS amount + FROM wallet_lots + WHERE organization_id = ? AND currency = ? AND remaining_amount > 0 + AND expires_at > ? AND expires_at <= ? + GROUP BY customer_id, day + ) + SELECT d.customer_id, to_char(d.day, 'YYYY-MM-DD') AS date, d.amount + FROM by_day d + LEFT JOIN wallet_expiry_reminders w + ON w.customer_id = d.customer_id AND w.currency = ? AND w.expiry_date = d.day + WHERE w.customer_id IS NULL + ORDER BY d.day, d.customer_id`, + walletDisplayTimeZone, organizationID, currency, asOf, until, currency).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to list upcoming expiry: %w", err) + } + return rows, nil +} + +func (r *walletExpiryRepository) MarkReminded(ctx context.Context, reminder UpcomingExpiry, currency string) (bool, error) { + res := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` + INSERT INTO wallet_expiry_reminders (customer_id, currency, expiry_date, amount) + VALUES (?, ?, ?::date, ?) + ON CONFLICT DO NOTHING`, reminder.CustomerID, currency, reminder.Date, reminder.Amount) + if res.Error != nil { + return false, fmt.Errorf("failed to record expiry reminder: %w", res.Error) + } + return res.RowsAffected == 1, nil +} diff --git a/internal/repository/wallet_query_repository.go b/internal/repository/wallet_query_repository.go index 0591693..ecd446f 100644 --- a/internal/repository/wallet_query_repository.go +++ b/internal/repository/wallet_query_repository.go @@ -47,6 +47,9 @@ type WalletQueryRepository interface { // NearestExpiring returns, per currency, the earliest day after asOf on which some // balance expires, and how much expires that day. NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) + // ExpiringByDay returns, per currency and day, everything that expires after asOf, + // soonest first. + ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) // ListTransactions returns a page of the ledger, newest first, and the total count. ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) // OrganizationOutstanding sums every wallet balance of an organization. @@ -181,3 +184,19 @@ func (r *walletQueryRepository) OrganizationOutstanding(ctx context.Context, org } return totals.Points, totals.Coins, nil } + +func (r *walletQueryRepository) ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) { + var rows []WalletExpiringAmount + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT currency, to_char((expires_at AT TIME ZONE ?)::date, 'YYYY-MM-DD') AS date, + SUM(remaining_amount) AS amount + FROM wallet_lots + WHERE customer_id = ? AND remaining_amount > 0 AND expires_at > ? + GROUP BY currency, date + ORDER BY date, currency`, walletDisplayTimeZone, customerID, asOf). + Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to list expiring wallet balance: %w", err) + } + return rows, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index d5770b0..d3a4b6f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -172,6 +172,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens) customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions) + customer.GET("/wallet/expiring", r.customerPointsHandler.GetCustomerWalletExpiring) customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode) customer.GET("/wallet/exchange/preview", r.customerWalletHandler.PreviewExchange) customer.POST("/wallet/exchange", r.customerWalletHandler.Exchange) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 0d377c9..40db753 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -28,6 +28,7 @@ func TestAllRoutesRegister(t *testing.T) { for _, want := range []string{ "GET /api/v1/customer/wallet", "GET /api/v1/customer/wallet/transactions", + "GET /api/v1/customer/wallet/expiring", "GET /api/v1/marketing/customers/:id/wallet", "POST /api/v1/marketing/customers/:id/wallet/adjust", "GET /api/v1/marketing/wallet-transactions/:id/trace", diff --git a/internal/service/customer_points_service.go b/internal/service/customer_points_service.go index a247845..6e85854 100644 --- a/internal/service/customer_points_service.go +++ b/internal/service/customer_points_service.go @@ -13,6 +13,7 @@ type CustomerPointsService interface { GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) + GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) GetCustomerGames(ctx context.Context) (*models.GetCustomerGamesResponse, error) GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) } @@ -90,3 +91,10 @@ func (s *customerPointsService) GetCustomerWalletTransactions(ctx context.Contex } return s.customerPointsProcessor.GetCustomerWalletTransactionsAPI(ctx, customerID, query) } + +func (s *customerPointsService) GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) { + if customerID == "" { + return nil, fmt.Errorf("customer ID is required") + } + return s.customerPointsProcessor.GetCustomerWalletExpiringAPI(ctx, customerID) +} diff --git a/internal/service/wallet_expiry_job.go b/internal/service/wallet_expiry_job.go index d671d15..f2a83c0 100644 --- a/internal/service/wallet_expiry_job.go +++ b/internal/service/wallet_expiry_job.go @@ -12,21 +12,23 @@ import ( // lot from staying past its expiry for more than about that long (PC-503). const defaultWalletExpiryInterval = 15 * time.Minute -type dueExpirer interface { +type walletExpiryWork interface { ExpireDue(ctx context.Context) (int, error) + SendReminders(ctx context.Context) (int, error) } -// WalletExpiryJob expires the balances whose time is up (docs/prd-point-coin.md F12). +// WalletExpiryJob expires the balances whose time is up and reminds customers of what +// is about to (docs/prd-point-coin.md F12, PC-503, PC-504). // Unlike OmsetMilestoneScheduler it keeps no state in memory: several instances can // run it at once, and a restart repeats nothing, because every lot is expired under // its wallet's lock with an idempotency key. type WalletExpiryJob struct { - expirer dueExpirer + expirer walletExpiryWork stopCh chan struct{} stopOnce sync.Once } -func NewWalletExpiryJob(expirer dueExpirer) *WalletExpiryJob { +func NewWalletExpiryJob(expirer walletExpiryWork) *WalletExpiryJob { return &WalletExpiryJob{expirer: expirer, stopCh: make(chan struct{})} } @@ -54,7 +56,8 @@ func (j *WalletExpiryJob) Stop() { j.stopOnce.Do(func() { close(j.stopCh) }) } -// RunOnce expires what is due and returns how many lots it expired. +// RunOnce expires what is due, sends the reminders that are due, and returns how many +// lots it expired. func (j *WalletExpiryJob) RunOnce(ctx context.Context) int { expired, err := j.expirer.ExpireDue(ctx) if err != nil { @@ -63,5 +66,12 @@ func (j *WalletExpiryJob) RunOnce(ctx context.Context) int { if expired > 0 { logger.NonContext.Infof("Wallet expiry expired %d lots", expired) } + reminded, err := j.expirer.SendReminders(ctx) + if err != nil { + logger.NonContext.Error("Wallet expiry reminders failed to run", err) + } + if reminded > 0 { + logger.NonContext.Infof("Wallet expiry sent %d reminders", reminded) + } return expired } diff --git a/migrations/000097_create_wallet_expiry_reminders.down.sql b/migrations/000097_create_wallet_expiry_reminders.down.sql new file mode 100644 index 0000000..a154829 --- /dev/null +++ b/migrations/000097_create_wallet_expiry_reminders.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS wallet_expiry_reminders; diff --git a/migrations/000097_create_wallet_expiry_reminders.up.sql b/migrations/000097_create_wallet_expiry_reminders.up.sql new file mode 100644 index 0000000..74e500e --- /dev/null +++ b/migrations/000097_create_wallet_expiry_reminders.up.sql @@ -0,0 +1,11 @@ +-- Which expiry reminders have gone out (docs/prd-point-coin.md F12): one per +-- customer, currency and expiry day. The row is written before the push is sent, so +-- several instances of the job, or a restart, never remind twice. +CREATE TABLE wallet_expiry_reminders ( + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + currency VARCHAR(10) NOT NULL CHECK (currency IN ('POINT','COIN')), + expiry_date DATE NOT NULL, + amount BIGINT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (customer_id, currency, expiry_date) +); -- 2.54.0 From d1e543a79f71e65e450cf2a7815f95a68406bb93 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 14:43:15 +0700 Subject: [PATCH 44/47] refactor(loyalty): remove the dead customer points and tokens code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First part of PC-601 (docs/prd-point-coin.md §10.7): the code that has had no way in since balances moved to the wallet. - The /marketing/customer-points and /marketing/customer-tokens routes were commented out; their 16 handler methods, the GamificationService methods behind them, and the validators, transformers, mappers and contract/model types only they used are gone. - CustomerPointsProcessor loses its "not implemented" stubs; it keeps the customer app's balance, wallet and games endpoints. - CustomerTokensProcessor and the customer points and tokens repositories, wired but no longer called by anything, are gone. What stays until its preconditions are met: the customer_points and customer_tokens tables and their entities, which cmd/wallet-migrate still reads, and the /customer/points, /customer/tokens aliases and the token_used / tokens_remaining fields, until the apps no longer use them. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 8 +- internal/contract/customer_points_contract.go | 58 --- internal/contract/customer_tokens_contract.go | 52 -- internal/handler/gamification_handler.go | 471 ------------------ internal/mappers/customer_points_mapper.go | 46 -- internal/mappers/customer_tokens_mapper.go | 48 -- internal/models/customer_points.go | 58 --- internal/models/customer_tokens.go | 44 -- .../processor/customer_points_processor.go | 47 -- .../processor/customer_tokens_processor.go | 198 -------- .../repository/customer_points_repository.go | 97 ---- .../repository/customer_tokens_repository.go | 131 ----- internal/router/router.go | 25 - internal/service/gamification_service.go | 174 +------ .../transformer/gamification_transformer.go | 125 ----- internal/validator/gamification_validator.go | 160 ------ 16 files changed, 11 insertions(+), 1731 deletions(-) delete mode 100644 internal/contract/customer_tokens_contract.go delete mode 100644 internal/mappers/customer_points_mapper.go delete mode 100644 internal/mappers/customer_tokens_mapper.go delete mode 100644 internal/models/customer_tokens.go delete mode 100644 internal/processor/customer_tokens_processor.go delete mode 100644 internal/repository/customer_points_repository.go delete mode 100644 internal/repository/customer_tokens_repository.go diff --git a/internal/app/app.go b/internal/app/app.go index df1bf1b..0e04b7e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -267,7 +267,6 @@ type repositories struct { chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl accountRepo *repository.AccountRepositoryImpl orderIngredientTransactionRepo *repository.OrderIngredientTransactionRepositoryImpl - customerTokensRepo *repository.CustomerTokensRepository tierRepo *repository.TierRepository gameRepo *repository.GameRepository gamePrizeRepo *repository.GamePrizeRepository @@ -277,7 +276,6 @@ type repositories struct { campaignRepo repository.CampaignRepository campaignRuleRepo repository.CampaignRuleRepository customerAuthRepo repository.CustomerAuthRepository - customerPointsRepo repository.CustomerPointsRepository otpRepo repository.OtpRepository sessionRepo repository.SessionRepository txManager *repository.TxManager @@ -325,7 +323,6 @@ func (a *App) initRepositories() *repositories { chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db), accountRepo: repository.NewAccountRepositoryImpl(a.db), orderIngredientTransactionRepo: repository.NewOrderIngredientTransactionRepositoryImpl(a.db).(*repository.OrderIngredientTransactionRepositoryImpl), - customerTokensRepo: repository.NewCustomerTokensRepository(a.db), tierRepo: repository.NewTierRepository(a.db), gameRepo: repository.NewGameRepository(a.db), gamePrizeRepo: repository.NewGamePrizeRepository(a.db), @@ -335,7 +332,6 @@ func (a *App) initRepositories() *repositories { campaignRepo: repository.NewCampaignRepository(a.db), campaignRuleRepo: repository.NewCampaignRuleRepository(a.db), customerAuthRepo: repository.NewCustomerAuthRepository(a.db), - customerPointsRepo: repository.NewCustomerPointsRepository(a.db), otpRepo: repository.NewOtpRepository(a.db), sessionRepo: repository.NewSessionRepository(a.redisClient), txManager: repository.NewTxManager(a.db), @@ -378,7 +374,6 @@ type processors struct { chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl accountProcessor *processor.AccountProcessorImpl orderIngredientTransactionProcessor *processor.OrderIngredientTransactionProcessorImpl - customerTokensProcessor *processor.CustomerTokensProcessor tierProcessor *processor.TierProcessor gameProcessor *processor.GameProcessor gamePrizeProcessor *processor.GamePrizeProcessor @@ -461,7 +456,6 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo), accountProcessor: processor.NewAccountProcessorImpl(repos.accountRepo, repos.chartOfAccountRepo), orderIngredientTransactionProcessor: processor.NewOrderIngredientTransactionProcessorImpl(repos.orderIngredientTransactionRepo, repos.productRecipeRepo, repos.ingredientRepo, repos.unitRepo).(*processor.OrderIngredientTransactionProcessorImpl), - customerTokensProcessor: processor.NewCustomerTokensProcessor(repos.customerTokensRepo), tierProcessor: processor.NewTierProcessor(repos.tierRepo), gameProcessor: processor.NewGameProcessor(repos.gameRepo), gamePrizeProcessor: processor.NewGamePrizeProcessor(repos.gamePrizeRepo), @@ -570,7 +564,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor) accountService := service.NewAccountService(processors.accountProcessor) orderIngredientTransactionService := service.NewOrderIngredientTransactionService(processors.orderIngredientTransactionProcessor, repos.txManager) - gamificationService := service.NewGamificationService(processors.customerPointsProcessor, processors.customerTokensProcessor, processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor) + gamificationService := service.NewGamificationService(processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor) rewardService := service.NewRewardService(processors.rewardProcessor) campaignService := service.NewCampaignService(processors.campaignProcessor, processors.campaignRuleProcessor) customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor) diff --git a/internal/contract/customer_points_contract.go b/internal/contract/customer_points_contract.go index d7cd65a..591758c 100644 --- a/internal/contract/customer_points_contract.go +++ b/internal/contract/customer_points_contract.go @@ -2,66 +2,8 @@ package contract import ( "time" - - "github.com/google/uuid" ) -// Existing gamification contracts -type CreateCustomerPointsRequest struct { - CustomerID uuid.UUID `json:"customer_id" validate:"required"` - Balance int64 `json:"balance" validate:"min=0"` -} - -type UpdateCustomerPointsRequest struct { - Balance int64 `json:"balance" validate:"min=0"` -} - -type AddCustomerPointsRequest struct { - Points int64 `json:"points" validate:"required,min=1"` -} - -type DeductCustomerPointsRequest struct { - Points int64 `json:"points" validate:"required,min=1"` -} - -type CustomerPointsResponse struct { - ID uuid.UUID `json:"id"` - CustomerID uuid.UUID `json:"customer_id"` - Balance int64 `json:"balance"` - Customer *CustomerResponse `json:"customer,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListCustomerPointsRequest struct { - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` - Search string `json:"search"` - SortBy string `json:"sort_by" validate:"omitempty,oneof=balance created_at updated_at"` - SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"` -} - -type PaginatedCustomerPointsResponse struct { - Data []CustomerPointsResponse `json:"data"` - TotalCount int `json:"total_count"` - Page int `json:"page"` - Limit int `json:"limit"` - TotalPages int `json:"total_pages"` -} - -// New customer API contracts -type GetCustomerPointsRequest struct { - // No additional fields needed - customer ID comes from JWT token -} - -type GetCustomerTokensRequest struct { - // No additional fields needed - customer ID comes from JWT token -} - -type GetCustomerWalletRequest struct { - // No additional fields needed - customer ID comes from JWT token -} - // Response Contracts type GetCustomerPointsResponse struct { Status string `json:"status"` diff --git a/internal/contract/customer_tokens_contract.go b/internal/contract/customer_tokens_contract.go deleted file mode 100644 index 21d90c6..0000000 --- a/internal/contract/customer_tokens_contract.go +++ /dev/null @@ -1,52 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateCustomerTokensRequest struct { - CustomerID uuid.UUID `json:"customer_id" validate:"required"` - TokenType string `json:"token_type" validate:"required,oneof=SPIN RAFFLE MINIGAME"` - Balance int64 `json:"balance" validate:"min=0"` -} - -type UpdateCustomerTokensRequest struct { - Balance int64 `json:"balance" validate:"min=0"` -} - -type AddCustomerTokensRequest struct { - Tokens int64 `json:"tokens" validate:"required,min=1"` -} - -type DeductCustomerTokensRequest struct { - Tokens int64 `json:"tokens" validate:"required,min=1"` -} - -type CustomerTokensResponse struct { - ID uuid.UUID `json:"id"` - CustomerID uuid.UUID `json:"customer_id"` - TokenType string `json:"token_type"` - Balance int64 `json:"balance"` - Customer *CustomerResponse `json:"customer,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListCustomerTokensRequest struct { - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` - Search string `json:"search"` - TokenType string `json:"token_type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"` - SortBy string `json:"sort_by" validate:"omitempty,oneof=balance token_type created_at updated_at"` - SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"` -} - -type PaginatedCustomerTokensResponse struct { - Data []CustomerTokensResponse `json:"data"` - TotalCount int `json:"total_count"` - Page int `json:"page"` - Limit int `json:"limit"` - TotalPages int `json:"total_pages"` -} diff --git a/internal/handler/gamification_handler.go b/internal/handler/gamification_handler.go index 4344a35..4c3c606 100644 --- a/internal/handler/gamification_handler.go +++ b/internal/handler/gamification_handler.go @@ -9,7 +9,6 @@ import ( "apskel-pos-be/internal/validator" "github.com/gin-gonic/gin" - "github.com/google/uuid" ) type GamificationHandler struct { @@ -27,239 +26,6 @@ func NewGamificationHandler( } } -// Customer Points Handlers -func (h *GamificationHandler) CreateCustomerPoints(c *gin.Context) { - ctx := c.Request.Context() - - var req contract.CreateCustomerPointsRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerPoints -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerPoints") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateCreateCustomerPointsRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateCustomerPoints -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerPoints") - return - } - - response, err := h.gamificationService.CreateCustomerPoints(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerPoints -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::CreateCustomerPoints") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateCustomerPoints") -} - -func (h *GamificationHandler) GetCustomerPoints(c *gin.Context) { - ctx := c.Request.Context() - - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPoints -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerPoints") - return - } - - response, err := h.gamificationService.GetCustomerPoints(ctx, id) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPoints -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::GetCustomerPoints") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerPoints") -} - -func (h *GamificationHandler) GetCustomerPointsByCustomerID(c *gin.Context) { - ctx := c.Request.Context() - customerIDStr := c.Param("customer_id") - customerID, err := uuid.Parse(customerIDStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPointsByCustomerID -> invalid customer ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerPointsByCustomerID") - return - } - - response, err := h.gamificationService.GetCustomerPointsByCustomerID(ctx, customerID) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPointsByCustomerID -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::GetCustomerPointsByCustomerID") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerPointsByCustomerID") -} - -func (h *GamificationHandler) ListCustomerPoints(c *gin.Context) { - ctx := c.Request.Context() - var req contract.ListCustomerPointsRequest - if err := c.ShouldBindQuery(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerPoints -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerPoints") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateListCustomerPointsRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListCustomerPoints -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerPoints") - return - } - - response, err := h.gamificationService.ListCustomerPoints(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerPoints -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::ListCustomerPoints") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListCustomerPoints") -} - -func (h *GamificationHandler) UpdateCustomerPoints(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerPoints -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints") - return - } - - var req contract.UpdateCustomerPointsRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerPoints -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateCustomerPointsRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateCustomerPoints -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints") - return - } - - response, err := h.gamificationService.UpdateCustomerPoints(ctx, id, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerPoints -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::UpdateCustomerPoints") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateCustomerPoints") -} - -func (h *GamificationHandler) DeleteCustomerPoints(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerPoints -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteCustomerPoints") - return - } - - err = h.gamificationService.DeleteCustomerPoints(ctx, id) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerPoints -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::DeleteCustomerPoints") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteCustomerPoints") -} - -func (h *GamificationHandler) AddCustomerPoints(c *gin.Context) { - ctx := c.Request.Context() - customerIDStr := c.Param("customer_id") - customerID, err := uuid.Parse(customerIDStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> invalid customer ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints") - return - } - - var req contract.AddCustomerPointsRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateAddCustomerPointsRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::AddCustomerPoints -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints") - return - } - - response, err := h.gamificationService.AddCustomerPoints(ctx, customerID, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::AddCustomerPoints") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::AddCustomerPoints") -} - -func (h *GamificationHandler) DeductCustomerPoints(c *gin.Context) { - ctx := c.Request.Context() - customerIDStr := c.Param("customer_id") - customerID, err := uuid.Parse(customerIDStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> invalid customer ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints") - return - } - - var req contract.DeductCustomerPointsRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateDeductCustomerPointsRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::DeductCustomerPoints -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints") - return - } - - response, err := h.gamificationService.DeductCustomerPoints(ctx, customerID, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::DeductCustomerPoints") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::DeductCustomerPoints") -} - // Play Game Handler func (h *GamificationHandler) PlayGame(c *gin.Context) { ctx := c.Request.Context() @@ -288,240 +54,3 @@ func (h *GamificationHandler) PlayGame(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::PlayGame") } - -// Additional handler methods for other gamification features -func (h *GamificationHandler) CreateCustomerTokens(c *gin.Context) { - ctx := c.Request.Context() - var req contract.CreateCustomerTokensRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerTokens -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerTokens") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateCreateCustomerTokensRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateCustomerTokens -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerTokens") - return - } - - response, err := h.gamificationService.CreateCustomerTokens(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerTokens -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::CreateCustomerTokens") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateCustomerTokens") -} - -func (h *GamificationHandler) GetCustomerTokens(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokens -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerTokens") - return - } - - response, err := h.gamificationService.GetCustomerTokens(ctx, id) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokens -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::GetCustomerTokens") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerTokens") -} - -func (h *GamificationHandler) GetCustomerTokensByCustomerIDAndType(c *gin.Context) { - ctx := c.Request.Context() - customerIDStr := c.Param("customer_id") - customerID, err := uuid.Parse(customerIDStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokensByCustomerIDAndType -> invalid customer ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerTokensByCustomerIDAndType") - return - } - - tokenType := c.Param("token_type") - - response, err := h.gamificationService.GetCustomerTokensByCustomerIDAndType(ctx, customerID, tokenType) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokensByCustomerIDAndType -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::GetCustomerTokensByCustomerIDAndType") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerTokensByCustomerIDAndType") -} - -func (h *GamificationHandler) ListCustomerTokens(c *gin.Context) { - ctx := c.Request.Context() - var req contract.ListCustomerTokensRequest - if err := c.ShouldBindQuery(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerTokens -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerTokens") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateListCustomerTokensRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListCustomerTokens -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerTokens") - return - } - - response, err := h.gamificationService.ListCustomerTokens(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerTokens -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::ListCustomerTokens") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListCustomerTokens") -} - -func (h *GamificationHandler) UpdateCustomerTokens(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerTokens -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens") - return - } - - var req contract.UpdateCustomerTokensRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerTokens -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateCustomerTokensRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateCustomerTokens -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens") - return - } - - response, err := h.gamificationService.UpdateCustomerTokens(ctx, id, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerTokens -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::UpdateCustomerTokens") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateCustomerTokens") -} - -func (h *GamificationHandler) DeleteCustomerTokens(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerTokens -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteCustomerTokens") - return - } - - err = h.gamificationService.DeleteCustomerTokens(ctx, id) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerTokens -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::DeleteCustomerTokens") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteCustomerTokens") -} - -func (h *GamificationHandler) AddCustomerTokens(c *gin.Context) { - ctx := c.Request.Context() - customerIDStr := c.Param("customer_id") - customerID, err := uuid.Parse(customerIDStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> invalid customer ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens") - return - } - - tokenType := c.Param("token_type") - - var req contract.AddCustomerTokensRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateAddCustomerTokensRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::AddCustomerTokens -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens") - return - } - - response, err := h.gamificationService.AddCustomerTokens(ctx, customerID, tokenType, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::AddCustomerTokens") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::AddCustomerTokens") -} - -func (h *GamificationHandler) DeductCustomerTokens(c *gin.Context) { - ctx := c.Request.Context() - customerIDStr := c.Param("customer_id") - customerID, err := uuid.Parse(customerIDStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> invalid customer ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens") - return - } - - tokenType := c.Param("token_type") - - var req contract.DeductCustomerTokensRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateDeductCustomerTokensRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::DeductCustomerTokens -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens") - return - } - - response, err := h.gamificationService.DeductCustomerTokens(ctx, customerID, tokenType, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::DeductCustomerTokens") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::DeductCustomerTokens") -} diff --git a/internal/mappers/customer_points_mapper.go b/internal/mappers/customer_points_mapper.go deleted file mode 100644 index 0cf67ba..0000000 --- a/internal/mappers/customer_points_mapper.go +++ /dev/null @@ -1,46 +0,0 @@ -package mappers - -import ( - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" -) - -// ToCustomerPointsResponse converts a customer points entity to a customer points response -func ToCustomerPointsResponse(customerPoints *entities.CustomerPoints) *models.CustomerPointsResponse { - if customerPoints == nil { - return nil - } - - return &models.CustomerPointsResponse{ - ID: customerPoints.ID, - CustomerID: customerPoints.CustomerID, - Balance: customerPoints.Balance, - Customer: ToCustomerResponse(&customerPoints.Customer), - CreatedAt: customerPoints.CreatedAt, - UpdatedAt: customerPoints.UpdatedAt, - } -} - -// ToCustomerPointsResponses converts a slice of customer points entities to customer points responses -func ToCustomerPointsResponses(customerPoints []entities.CustomerPoints) []models.CustomerPointsResponse { - responses := make([]models.CustomerPointsResponse, len(customerPoints)) - for i, cp := range customerPoints { - responses[i] = *ToCustomerPointsResponse(&cp) - } - return responses -} - -// ToCustomerPointsEntity converts a create customer points request to a customer points entity -func ToCustomerPointsEntity(req *models.CreateCustomerPointsRequest) *entities.CustomerPoints { - return &entities.CustomerPoints{ - CustomerID: req.CustomerID, - Balance: req.Balance, - } -} - -// UpdateCustomerPointsEntity updates a customer points entity with update request data -func UpdateCustomerPointsEntity(customerPoints *entities.CustomerPoints, req *models.UpdateCustomerPointsRequest) { - if req.Balance >= 0 { - customerPoints.Balance = req.Balance - } -} diff --git a/internal/mappers/customer_tokens_mapper.go b/internal/mappers/customer_tokens_mapper.go deleted file mode 100644 index e88cfed..0000000 --- a/internal/mappers/customer_tokens_mapper.go +++ /dev/null @@ -1,48 +0,0 @@ -package mappers - -import ( - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" -) - -// ToCustomerTokensResponse converts a customer tokens entity to a customer tokens response -func ToCustomerTokensResponse(customerTokens *entities.CustomerTokens) *models.CustomerTokensResponse { - if customerTokens == nil { - return nil - } - - return &models.CustomerTokensResponse{ - ID: customerTokens.ID, - CustomerID: customerTokens.CustomerID, - TokenType: string(customerTokens.TokenType), - Balance: customerTokens.Balance, - Customer: ToCustomerResponse(&customerTokens.Customer), - CreatedAt: customerTokens.CreatedAt, - UpdatedAt: customerTokens.UpdatedAt, - } -} - -// ToCustomerTokensResponses converts a slice of customer tokens entities to customer tokens responses -func ToCustomerTokensResponses(customerTokens []entities.CustomerTokens) []models.CustomerTokensResponse { - responses := make([]models.CustomerTokensResponse, len(customerTokens)) - for i, ct := range customerTokens { - responses[i] = *ToCustomerTokensResponse(&ct) - } - return responses -} - -// ToCustomerTokensEntity converts a create customer tokens request to a customer tokens entity -func ToCustomerTokensEntity(req *models.CreateCustomerTokensRequest) *entities.CustomerTokens { - return &entities.CustomerTokens{ - CustomerID: req.CustomerID, - TokenType: entities.TokenType(req.TokenType), - Balance: req.Balance, - } -} - -// UpdateCustomerTokensEntity updates a customer tokens entity with update request data -func UpdateCustomerTokensEntity(customerTokens *entities.CustomerTokens, req *models.UpdateCustomerTokensRequest) { - if req.Balance >= 0 { - customerTokens.Balance = req.Balance - } -} diff --git a/internal/models/customer_points.go b/internal/models/customer_points.go index c4b4ca9..9e1c84f 100644 --- a/internal/models/customer_points.go +++ b/internal/models/customer_points.go @@ -2,66 +2,8 @@ package models import ( "time" - - "github.com/google/uuid" ) -// Existing gamification models -type CreateCustomerPointsRequest struct { - CustomerID uuid.UUID `json:"customer_id" validate:"required"` - Balance int64 `json:"balance" validate:"min=0"` -} - -type UpdateCustomerPointsRequest struct { - Balance int64 `json:"balance" validate:"min=0"` -} - -type AddCustomerPointsRequest struct { - Balance int64 `json:"balance" validate:"required,min=1"` -} - -type DeductCustomerPointsRequest struct { - Balance int64 `json:"balance" validate:"required,min=1"` -} - -type CustomerPointsResponse struct { - ID uuid.UUID `json:"id"` - CustomerID uuid.UUID `json:"customer_id"` - Balance int64 `json:"balance"` - Customer *CustomerResponse `json:"customer,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListCustomerPointsQuery struct { - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` - Search string `json:"search"` - SortBy string `json:"sort_by" validate:"omitempty,oneof=balance created_at updated_at"` - SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"` -} - -type PaginatedCustomerPointsResponse struct { - Data []CustomerPointsResponse `json:"data"` - TotalCount int `json:"total_count"` - Page int `json:"page"` - Limit int `json:"limit"` - TotalPages int `json:"total_pages"` -} - -// New customer API models -type GetCustomerPointsRequest struct { - // No additional fields needed - customer ID comes from JWT token -} - -type GetCustomerTokensRequest struct { - // No additional fields needed - customer ID comes from JWT token -} - -type GetCustomerWalletRequest struct { - // No additional fields needed - customer ID comes from JWT token -} - // Response Models type GetCustomerPointsResponse struct { Status string `json:"status"` diff --git a/internal/models/customer_tokens.go b/internal/models/customer_tokens.go deleted file mode 100644 index 7ee4455..0000000 --- a/internal/models/customer_tokens.go +++ /dev/null @@ -1,44 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateCustomerTokensRequest struct { - CustomerID uuid.UUID `json:"customer_id" validate:"required"` - TokenType string `json:"token_type" validate:"required,oneof=SPIN RAFFLE MINIGAME"` - Balance int64 `json:"balance" validate:"min=0"` -} - -type UpdateCustomerTokensRequest struct { - Balance int64 `json:"balance" validate:"min=0"` -} - -type AddCustomerTokensRequest struct { - Tokens int64 `json:"tokens" validate:"required,min=1"` -} - -type DeductCustomerTokensRequest struct { - Tokens int64 `json:"tokens" validate:"required,min=1"` -} - -type CustomerTokensResponse struct { - ID uuid.UUID `json:"id"` - CustomerID uuid.UUID `json:"customer_id"` - TokenType string `json:"token_type"` - Balance int64 `json:"balance"` - Customer *CustomerResponse `json:"customer,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListCustomerTokensQuery struct { - Page int `query:"page" validate:"min=1"` - Limit int `query:"limit" validate:"min=1,max=100"` - Search string `query:"search"` - TokenType string `query:"token_type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=balance token_type created_at updated_at"` - SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` -} diff --git a/internal/processor/customer_points_processor.go b/internal/processor/customer_points_processor.go index bd87c33..a5a96aa 100644 --- a/internal/processor/customer_points_processor.go +++ b/internal/processor/customer_points_processor.go @@ -22,53 +22,6 @@ func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *rep } } -// Existing gamification methods - placeholder implementations -func (p *CustomerPointsProcessor) CreateCustomerPoints(ctx context.Context, req *models.CreateCustomerPointsRequest) (*models.CustomerPointsResponse, error) { - // TODO: Implement this method - return nil, fmt.Errorf("not implemented") -} - -func (p *CustomerPointsProcessor) GetCustomerPoints(ctx context.Context, id uuid.UUID) (*models.CustomerPointsResponse, error) { - // TODO: Implement this method - return nil, fmt.Errorf("not implemented") -} - -func (p *CustomerPointsProcessor) GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*models.CustomerPointsResponse, error) { - // TODO: Implement this method - return nil, fmt.Errorf("not implemented") -} - -func (p *CustomerPointsProcessor) ListCustomerPoints(ctx context.Context, query *models.ListCustomerPointsQuery) (*models.PaginatedCustomerPointsResponse, error) { - // Return empty paginated response for now - return &models.PaginatedCustomerPointsResponse{ - Data: []models.CustomerPointsResponse{}, - TotalCount: 0, - Page: 1, - Limit: 10, - TotalPages: 0, - }, nil -} - -func (p *CustomerPointsProcessor) UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *models.UpdateCustomerPointsRequest) (*models.CustomerPointsResponse, error) { - // TODO: Implement this method - return nil, fmt.Errorf("not implemented") -} - -func (p *CustomerPointsProcessor) DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error { - // TODO: Implement this method - return fmt.Errorf("not implemented") -} - -func (p *CustomerPointsProcessor) AddPoints(ctx context.Context, customerID uuid.UUID, points int64) (*models.CustomerPointsResponse, error) { - // TODO: Implement this method - return nil, fmt.Errorf("not implemented") -} - -func (p *CustomerPointsProcessor) DeductPoints(ctx context.Context, customerID uuid.UUID, points int64) (*models.CustomerPointsResponse, error) { - // TODO: Implement this method - return nil, fmt.Errorf("not implemented") -} - // The three endpoints below are the customer app's balance screens. They read the // EnakPoint & EnakCoin wallet; customer_points and customer_tokens are no longer used // for balances (docs/prd-point-coin.md §9). diff --git a/internal/processor/customer_tokens_processor.go b/internal/processor/customer_tokens_processor.go deleted file mode 100644 index f66e693..0000000 --- a/internal/processor/customer_tokens_processor.go +++ /dev/null @@ -1,198 +0,0 @@ -package processor - -import ( - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/mappers" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" - "context" - "errors" - "fmt" - - "github.com/google/uuid" -) - -type CustomerTokensProcessor struct { - customerTokensRepo *repository.CustomerTokensRepository -} - -func NewCustomerTokensProcessor(customerTokensRepo *repository.CustomerTokensRepository) *CustomerTokensProcessor { - return &CustomerTokensProcessor{ - customerTokensRepo: customerTokensRepo, - } -} - -// CreateCustomerTokens creates a new customer tokens record -func (p *CustomerTokensProcessor) CreateCustomerTokens(ctx context.Context, req *models.CreateCustomerTokensRequest) (*models.CustomerTokensResponse, error) { - // Convert request to entity - customerTokens := mappers.ToCustomerTokensEntity(req) - - // Create customer tokens - err := p.customerTokensRepo.Create(ctx, customerTokens) - if err != nil { - return nil, fmt.Errorf("failed to create customer tokens: %w", err) - } - - return mappers.ToCustomerTokensResponse(customerTokens), nil -} - -// GetCustomerTokens retrieves customer tokens by ID -func (p *CustomerTokensProcessor) GetCustomerTokens(ctx context.Context, id uuid.UUID) (*models.CustomerTokensResponse, error) { - customerTokens, err := p.customerTokensRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("customer tokens not found: %w", err) - } - - return mappers.ToCustomerTokensResponse(customerTokens), nil -} - -// GetCustomerTokensByCustomerIDAndType retrieves customer tokens by customer ID and token type -func (p *CustomerTokensProcessor) GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*models.CustomerTokensResponse, error) { - customerTokens, err := p.customerTokensRepo.EnsureCustomerTokens(ctx, customerID, entities.TokenType(tokenType)) - if err != nil { - return nil, fmt.Errorf("failed to get customer tokens: %w", err) - } - - return mappers.ToCustomerTokensResponse(customerTokens), nil -} - -// ListCustomerTokens retrieves customer tokens with pagination and filtering -func (p *CustomerTokensProcessor) ListCustomerTokens(ctx context.Context, query *models.ListCustomerTokensQuery) (*models.PaginatedResponse[models.CustomerTokensResponse], error) { - // Set default values - if query.Page <= 0 { - query.Page = 1 - } - if query.Limit <= 0 { - query.Limit = 10 - } - if query.Limit > 100 { - query.Limit = 100 - } - - offset := (query.Page - 1) * query.Limit - - // Get customer tokens from repository - customerTokens, total, err := p.customerTokensRepo.List( - ctx, - offset, - query.Limit, - query.Search, - query.TokenType, - query.SortBy, - query.SortOrder, - ) - if err != nil { - return nil, fmt.Errorf("failed to list customer tokens: %w", err) - } - - // Convert to responses - responses := mappers.ToCustomerTokensResponses(customerTokens) - - // Calculate pagination info - totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit)) - - return &models.PaginatedResponse[models.CustomerTokensResponse]{ - Data: responses, - Pagination: models.Pagination{ - Page: query.Page, - Limit: query.Limit, - Total: total, - TotalPages: totalPages, - }, - }, nil -} - -// UpdateCustomerTokens updates an existing customer tokens record -func (p *CustomerTokensProcessor) UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *models.UpdateCustomerTokensRequest) (*models.CustomerTokensResponse, error) { - // Get existing customer tokens - customerTokens, err := p.customerTokensRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("customer tokens not found: %w", err) - } - - // Update customer tokens fields - mappers.UpdateCustomerTokensEntity(customerTokens, req) - - // Save updated customer tokens - err = p.customerTokensRepo.Update(ctx, customerTokens) - if err != nil { - return nil, fmt.Errorf("failed to update customer tokens: %w", err) - } - - return mappers.ToCustomerTokensResponse(customerTokens), nil -} - -// DeleteCustomerTokens deletes a customer tokens record -func (p *CustomerTokensProcessor) DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error { - // Get existing customer tokens - _, err := p.customerTokensRepo.GetByID(ctx, id) - if err != nil { - return fmt.Errorf("customer tokens not found: %w", err) - } - - // Delete customer tokens - err = p.customerTokensRepo.Delete(ctx, id) - if err != nil { - return fmt.Errorf("failed to delete customer tokens: %w", err) - } - - return nil -} - -// AddTokens adds tokens to a customer's balance -func (p *CustomerTokensProcessor) AddTokens(ctx context.Context, customerID uuid.UUID, tokenType string, tokens int64) (*models.CustomerTokensResponse, error) { - if tokens <= 0 { - return nil, errors.New("tokens must be greater than 0") - } - - // Ensure customer tokens record exists - _, err := p.customerTokensRepo.EnsureCustomerTokens(ctx, customerID, entities.TokenType(tokenType)) - if err != nil { - return nil, fmt.Errorf("failed to ensure customer tokens: %w", err) - } - - // Add tokens - err = p.customerTokensRepo.AddTokens(ctx, customerID, entities.TokenType(tokenType), tokens) - if err != nil { - return nil, fmt.Errorf("failed to add tokens: %w", err) - } - - // Get updated customer tokens - customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType)) - if err != nil { - return nil, fmt.Errorf("failed to get updated customer tokens: %w", err) - } - - return mappers.ToCustomerTokensResponse(customerTokens), nil -} - -// DeductTokens deducts tokens from a customer's balance -func (p *CustomerTokensProcessor) DeductTokens(ctx context.Context, customerID uuid.UUID, tokenType string, tokens int64) (*models.CustomerTokensResponse, error) { - if tokens <= 0 { - return nil, errors.New("tokens must be greater than 0") - } - - // Get current customer tokens - customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType)) - if err != nil { - return nil, fmt.Errorf("customer tokens not found: %w", err) - } - - if customerTokens.Balance < tokens { - return nil, errors.New("insufficient tokens balance") - } - - // Deduct tokens - err = p.customerTokensRepo.DeductTokens(ctx, customerID, entities.TokenType(tokenType), tokens) - if err != nil { - return nil, fmt.Errorf("failed to deduct tokens: %w", err) - } - - // Get updated customer tokens - updatedCustomerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType)) - if err != nil { - return nil, fmt.Errorf("failed to get updated customer tokens: %w", err) - } - - return mappers.ToCustomerTokensResponse(updatedCustomerTokens), nil -} diff --git a/internal/repository/customer_points_repository.go b/internal/repository/customer_points_repository.go deleted file mode 100644 index 6ee1677..0000000 --- a/internal/repository/customer_points_repository.go +++ /dev/null @@ -1,97 +0,0 @@ -package repository - -import ( - "context" - "fmt" - - "apskel-pos-be/internal/entities" - - "gorm.io/gorm" -) - -type CustomerPointsRepository interface { - GetCustomerTotalPoints(ctx context.Context, customerID string) (int64, error) - GetCustomerTotalTokens(ctx context.Context, customerID string) (int64, error) - GetCustomerPointsHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerPoints, error) - GetCustomerTokensHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerTokens, error) -} - -type customerPointsRepository struct { - db *gorm.DB -} - -func NewCustomerPointsRepository(db *gorm.DB) CustomerPointsRepository { - return &customerPointsRepository{ - db: db, - } -} - -func (r *customerPointsRepository) GetCustomerTotalPoints(ctx context.Context, customerID string) (int64, error) { - var totalPoints int64 - - err := r.db.WithContext(ctx). - Model(&entities.CustomerPoints{}). - Where("customer_id = ?", customerID). - Select("COALESCE(SUM(balance), 0)"). - Scan(&totalPoints).Error - - if err != nil { - return 0, fmt.Errorf("failed to get customer total points: %w", err) - } - - return totalPoints, nil -} - -func (r *customerPointsRepository) GetCustomerTotalTokens(ctx context.Context, customerID string) (int64, error) { - var totalTokens int64 - - err := r.db.WithContext(ctx). - Model(&entities.CustomerTokens{}). - Where("customer_id = ?", customerID). - Select("COALESCE(SUM(balance), 0)"). - Scan(&totalTokens).Error - - if err != nil { - return 0, fmt.Errorf("failed to get customer total tokens: %w", err) - } - - return totalTokens, nil -} - -func (r *customerPointsRepository) GetCustomerPointsHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerPoints, error) { - var pointsHistory []entities.CustomerPoints - - query := r.db.WithContext(ctx). - Where("customer_id = ?", customerID). - Order("created_at DESC") - - if limit > 0 { - query = query.Limit(limit) - } - - err := query.Find(&pointsHistory).Error - if err != nil { - return nil, fmt.Errorf("failed to get customer points history: %w", err) - } - - return pointsHistory, nil -} - -func (r *customerPointsRepository) GetCustomerTokensHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerTokens, error) { - var tokensHistory []entities.CustomerTokens - - query := r.db.WithContext(ctx). - Where("customer_id = ?", customerID). - Order("created_at DESC") - - if limit > 0 { - query = query.Limit(limit) - } - - err := query.Find(&tokensHistory).Error - if err != nil { - return nil, fmt.Errorf("failed to get customer tokens history: %w", err) - } - - return tokensHistory, nil -} diff --git a/internal/repository/customer_tokens_repository.go b/internal/repository/customer_tokens_repository.go deleted file mode 100644 index de29903..0000000 --- a/internal/repository/customer_tokens_repository.go +++ /dev/null @@ -1,131 +0,0 @@ -package repository - -import ( - "apskel-pos-be/internal/entities" - "context" - "fmt" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type CustomerTokensRepository struct { - db *gorm.DB -} - -func NewCustomerTokensRepository(db *gorm.DB) *CustomerTokensRepository { - return &CustomerTokensRepository{db: db} -} - -func (r *CustomerTokensRepository) Create(ctx context.Context, customerTokens *entities.CustomerTokens) error { - return r.db.WithContext(ctx).Create(customerTokens).Error -} - -func (r *CustomerTokensRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.CustomerTokens, error) { - var customerTokens entities.CustomerTokens - err := r.db.WithContext(ctx).Preload("Customer").Where("id = ?", id).First(&customerTokens).Error - if err != nil { - return nil, err - } - return &customerTokens, nil -} - -func (r *CustomerTokensRepository) GetByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType) (*entities.CustomerTokens, error) { - var customerTokens entities.CustomerTokens - err := r.db.WithContext(ctx).Preload("Customer").Where("customer_id = ? AND token_type = ?", customerID, tokenType).First(&customerTokens).Error - if err != nil { - return nil, err - } - return &customerTokens, nil -} - -func (r *CustomerTokensRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID) ([]entities.CustomerTokens, error) { - var customerTokens []entities.CustomerTokens - err := r.db.WithContext(ctx).Preload("Customer").Where("customer_id = ?", customerID).Find(&customerTokens).Error - if err != nil { - return nil, err - } - return customerTokens, nil -} - -func (r *CustomerTokensRepository) List(ctx context.Context, offset, limit int, search, tokenType string, sortBy, sortOrder string) ([]entities.CustomerTokens, int64, error) { - var customerTokens []entities.CustomerTokens - var total int64 - - query := r.db.WithContext(ctx).Preload("Customer") - - if search != "" { - searchTerm := "%" + search + "%" - query = query.Joins("JOIN customers ON customer_tokens.customer_id = customers.id"). - Where("customers.name ILIKE ? OR customers.email ILIKE ?", searchTerm, searchTerm) - } - - if tokenType != "" { - query = query.Where("token_type = ?", tokenType) - } - - if err := query.Model(&entities.CustomerTokens{}).Count(&total).Error; err != nil { - return nil, 0, err - } - - if sortBy != "" { - if sortOrder == "" { - sortOrder = "asc" - } - query = query.Order(fmt.Sprintf("customer_tokens.%s %s", sortBy, sortOrder)) - } else { - query = query.Order("customer_tokens.created_at DESC") - } - - err := query.Offset(offset).Limit(limit).Find(&customerTokens).Error - if err != nil { - return nil, 0, err - } - - return customerTokens, total, nil -} - -func (r *CustomerTokensRepository) Update(ctx context.Context, customerTokens *entities.CustomerTokens) error { - return r.db.WithContext(ctx).Save(customerTokens).Error -} - -func (r *CustomerTokensRepository) Delete(ctx context.Context, id uuid.UUID) error { - return r.db.WithContext(ctx).Delete(&entities.CustomerTokens{}, id).Error -} - -func (r *CustomerTokensRepository) AddTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType, tokens int64) error { - return r.db.WithContext(ctx).Model(&entities.CustomerTokens{}). - Where("customer_id = ? AND token_type = ?", customerID, tokenType). - Update("balance", gorm.Expr("balance + ?", tokens)).Error -} - -func (r *CustomerTokensRepository) DeductTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType, tokens int64) error { - return r.db.WithContext(ctx).Model(&entities.CustomerTokens{}). - Where("customer_id = ? AND token_type = ? AND balance >= ?", customerID, tokenType, tokens). - Update("balance", gorm.Expr("balance - ?", tokens)).Error -} - -func (r *CustomerTokensRepository) EnsureCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType) (*entities.CustomerTokens, error) { - customerTokens, err := r.GetByCustomerIDAndType(ctx, customerID, tokenType) - if err == nil { - return customerTokens, nil - } - - if err != gorm.ErrRecordNotFound { - return nil, err - } - - // Create new customer tokens record - newCustomerTokens := &entities.CustomerTokens{ - CustomerID: customerID, - TokenType: tokenType, - Balance: 0, - } - - err = r.Create(ctx, newCustomerTokens) - if err != nil { - return nil, err - } - - return newCustomerTokens, nil -} diff --git a/internal/router/router.go b/internal/router/router.go index d3a4b6f..a06e26e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -542,31 +542,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { gamification := protected.Group("/marketing") gamification.Use(r.authMiddleware.RequireAdminOrManager()) { - //customerPoints := gamification.Group("/customer-points") - //{ - // customerPoints.POST("", r.gamificationHandler.CreateCustomerPoints) - // customerPoints.GET("", r.gamificationHandler.ListCustomerPoints) - // customerPoints.GET("/:id", r.gamificationHandler.GetCustomerPoints) - // customerPoints.PUT("/:id", r.gamificationHandler.UpdateCustomerPoints) - // customerPoints.DELETE("/:id", r.gamificationHandler.DeleteCustomerPoints) - // customerPoints.GET("/customer/:customer_id", r.gamificationHandler.GetCustomerPointsByCustomerID) - // customerPoints.POST("/customer/:customer_id/add", r.gamificationHandler.AddCustomerPoints) - // customerPoints.POST("/customer/:customer_id/deduct", r.gamificationHandler.DeductCustomerPoints) - //} - - // Customer Tokens - //customerTokens := gamification.Group("/customer-tokens") - //{ - // customerTokens.POST("", r.gamificationHandler.CreateCustomerTokens) - // customerTokens.GET("", r.gamificationHandler.ListCustomerTokens) - // customerTokens.GET("/:id", r.gamificationHandler.GetCustomerTokens) - // customerTokens.PUT("/:id", r.gamificationHandler.UpdateCustomerTokens) - // customerTokens.DELETE("/:id", r.gamificationHandler.DeleteCustomerTokens) - // customerTokens.GET("/customer/:customer_id/type/:token_type", r.gamificationHandler.GetCustomerTokensByCustomerIDAndType) - // customerTokens.POST("/customer/:customer_id/type/:token_type/add", r.gamificationHandler.AddCustomerTokens) - // customerTokens.POST("/customer/:customer_id/type/:token_type/deduct", r.gamificationHandler.DeductCustomerTokens) - //} - // Tiers tiers := gamification.Group("/tiers") { diff --git a/internal/service/gamification_service.go b/internal/service/gamification_service.go index 6b5b22c..7096fe1 100644 --- a/internal/service/gamification_service.go +++ b/internal/service/gamification_service.go @@ -11,26 +11,6 @@ import ( ) type GamificationService interface { - // Customer Points - CreateCustomerPoints(ctx context.Context, req *contract.CreateCustomerPointsRequest) (*contract.CustomerPointsResponse, error) - GetCustomerPoints(ctx context.Context, id uuid.UUID) (*contract.CustomerPointsResponse, error) - GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*contract.CustomerPointsResponse, error) - ListCustomerPoints(ctx context.Context, query *contract.ListCustomerPointsRequest) (*contract.PaginatedCustomerPointsResponse, error) - UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerPointsRequest) (*contract.CustomerPointsResponse, error) - DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error - AddCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.AddCustomerPointsRequest) (*contract.CustomerPointsResponse, error) - DeductCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.DeductCustomerPointsRequest) (*contract.CustomerPointsResponse, error) - - // Customer Tokens - CreateCustomerTokens(ctx context.Context, req *contract.CreateCustomerTokensRequest) (*contract.CustomerTokensResponse, error) - GetCustomerTokens(ctx context.Context, id uuid.UUID) (*contract.CustomerTokensResponse, error) - GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*contract.CustomerTokensResponse, error) - ListCustomerTokens(ctx context.Context, query *contract.ListCustomerTokensRequest) (*contract.PaginatedCustomerTokensResponse, error) - UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerTokensRequest) (*contract.CustomerTokensResponse, error) - DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error - AddCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.AddCustomerTokensRequest) (*contract.CustomerTokensResponse, error) - DeductCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.DeductCustomerTokensRequest) (*contract.CustomerTokensResponse, error) - // Tiers CreateTier(ctx context.Context, req *contract.CreateTierRequest) (*contract.TierResponse, error) GetTier(ctx context.Context, id uuid.UUID) (*contract.TierResponse, error) @@ -72,18 +52,14 @@ type GamificationService interface { } type GamificationServiceImpl struct { - customerPointsProcessor *processor.CustomerPointsProcessor - customerTokensProcessor *processor.CustomerTokensProcessor - tierProcessor *processor.TierProcessor - gameProcessor *processor.GameProcessor - gamePrizeProcessor *processor.GamePrizeProcessor - gamePlayProcessor *processor.GamePlayProcessor - omsetTrackerProcessor *processor.OmsetTrackerProcessor + tierProcessor *processor.TierProcessor + gameProcessor *processor.GameProcessor + gamePrizeProcessor *processor.GamePrizeProcessor + gamePlayProcessor *processor.GamePlayProcessor + omsetTrackerProcessor *processor.OmsetTrackerProcessor } func NewGamificationService( - customerPointsProcessor *processor.CustomerPointsProcessor, - customerTokensProcessor *processor.CustomerTokensProcessor, tierProcessor *processor.TierProcessor, gameProcessor *processor.GameProcessor, gamePrizeProcessor *processor.GamePrizeProcessor, @@ -91,144 +67,14 @@ func NewGamificationService( omsetTrackerProcessor *processor.OmsetTrackerProcessor, ) *GamificationServiceImpl { return &GamificationServiceImpl{ - customerPointsProcessor: customerPointsProcessor, - customerTokensProcessor: customerTokensProcessor, - tierProcessor: tierProcessor, - gameProcessor: gameProcessor, - gamePrizeProcessor: gamePrizeProcessor, - gamePlayProcessor: gamePlayProcessor, - omsetTrackerProcessor: omsetTrackerProcessor, + tierProcessor: tierProcessor, + gameProcessor: gameProcessor, + gamePrizeProcessor: gamePrizeProcessor, + gamePlayProcessor: gamePlayProcessor, + omsetTrackerProcessor: omsetTrackerProcessor, } } -// Customer Points Service Methods -func (s *GamificationServiceImpl) CreateCustomerPoints(ctx context.Context, req *contract.CreateCustomerPointsRequest) (*contract.CustomerPointsResponse, error) { - modelReq := transformer.CreateCustomerPointsRequestToModel(req) - response, err := s.customerPointsProcessor.CreateCustomerPoints(ctx, modelReq) - if err != nil { - return nil, err - } - return transformer.CustomerPointsModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) GetCustomerPoints(ctx context.Context, id uuid.UUID) (*contract.CustomerPointsResponse, error) { - response, err := s.customerPointsProcessor.GetCustomerPoints(ctx, id) - if err != nil { - return nil, err - } - return transformer.CustomerPointsModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*contract.CustomerPointsResponse, error) { - response, err := s.customerPointsProcessor.GetCustomerPointsByCustomerID(ctx, customerID) - if err != nil { - return nil, err - } - return transformer.CustomerPointsModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) ListCustomerPoints(ctx context.Context, query *contract.ListCustomerPointsRequest) (*contract.PaginatedCustomerPointsResponse, error) { - modelQuery := transformer.ListCustomerPointsRequestToModel(query) - response, err := s.customerPointsProcessor.ListCustomerPoints(ctx, modelQuery) - if err != nil { - return nil, err - } - return transformer.PaginatedCustomerPointsResponseToContract(response), nil -} - -func (s *GamificationServiceImpl) UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerPointsRequest) (*contract.CustomerPointsResponse, error) { - modelReq := transformer.UpdateCustomerPointsRequestToModel(req) - response, err := s.customerPointsProcessor.UpdateCustomerPoints(ctx, id, modelReq) - if err != nil { - return nil, err - } - return transformer.CustomerPointsModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error { - return s.customerPointsProcessor.DeleteCustomerPoints(ctx, id) -} - -func (s *GamificationServiceImpl) AddCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.AddCustomerPointsRequest) (*contract.CustomerPointsResponse, error) { - response, err := s.customerPointsProcessor.AddPoints(ctx, customerID, req.Points) - if err != nil { - return nil, err - } - return transformer.CustomerPointsModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) DeductCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.DeductCustomerPointsRequest) (*contract.CustomerPointsResponse, error) { - response, err := s.customerPointsProcessor.DeductPoints(ctx, customerID, req.Points) - if err != nil { - return nil, err - } - return transformer.CustomerPointsModelToResponse(response), nil -} - -// Customer Tokens Service Methods -func (s *GamificationServiceImpl) CreateCustomerTokens(ctx context.Context, req *contract.CreateCustomerTokensRequest) (*contract.CustomerTokensResponse, error) { - modelReq := transformer.CreateCustomerTokensRequestToModel(req) - response, err := s.customerTokensProcessor.CreateCustomerTokens(ctx, modelReq) - if err != nil { - return nil, err - } - return transformer.CustomerTokensModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) GetCustomerTokens(ctx context.Context, id uuid.UUID) (*contract.CustomerTokensResponse, error) { - response, err := s.customerTokensProcessor.GetCustomerTokens(ctx, id) - if err != nil { - return nil, err - } - return transformer.CustomerTokensModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*contract.CustomerTokensResponse, error) { - response, err := s.customerTokensProcessor.GetCustomerTokensByCustomerIDAndType(ctx, customerID, tokenType) - if err != nil { - return nil, err - } - return transformer.CustomerTokensModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) ListCustomerTokens(ctx context.Context, query *contract.ListCustomerTokensRequest) (*contract.PaginatedCustomerTokensResponse, error) { - modelQuery := transformer.ListCustomerTokensRequestToModel(query) - response, err := s.customerTokensProcessor.ListCustomerTokens(ctx, modelQuery) - if err != nil { - return nil, err - } - return transformer.PaginatedCustomerTokensResponseToContract(response), nil -} - -func (s *GamificationServiceImpl) UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerTokensRequest) (*contract.CustomerTokensResponse, error) { - modelReq := transformer.UpdateCustomerTokensRequestToModel(req) - response, err := s.customerTokensProcessor.UpdateCustomerTokens(ctx, id, modelReq) - if err != nil { - return nil, err - } - return transformer.CustomerTokensModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error { - return s.customerTokensProcessor.DeleteCustomerTokens(ctx, id) -} - -func (s *GamificationServiceImpl) AddCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.AddCustomerTokensRequest) (*contract.CustomerTokensResponse, error) { - response, err := s.customerTokensProcessor.AddTokens(ctx, customerID, tokenType, req.Tokens) - if err != nil { - return nil, err - } - return transformer.CustomerTokensModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) DeductCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.DeductCustomerTokensRequest) (*contract.CustomerTokensResponse, error) { - response, err := s.customerTokensProcessor.DeductTokens(ctx, customerID, tokenType, req.Tokens) - if err != nil { - return nil, err - } - return transformer.CustomerTokensModelToResponse(response), nil -} - // Tier Service Methods func (s *GamificationServiceImpl) CreateTier(ctx context.Context, req *contract.CreateTierRequest) (*contract.TierResponse, error) { modelReq := transformer.CreateTierRequestToModel(req) diff --git a/internal/transformer/gamification_transformer.go b/internal/transformer/gamification_transformer.go index a1a0dcd..0b66a63 100644 --- a/internal/transformer/gamification_transformer.go +++ b/internal/transformer/gamification_transformer.go @@ -5,131 +5,6 @@ import ( "apskel-pos-be/internal/models" ) -// Customer Points Transformers -func CreateCustomerPointsRequestToModel(req *contract.CreateCustomerPointsRequest) *models.CreateCustomerPointsRequest { - return &models.CreateCustomerPointsRequest{ - CustomerID: req.CustomerID, - Balance: req.Balance, - } -} - -func UpdateCustomerPointsRequestToModel(req *contract.UpdateCustomerPointsRequest) *models.UpdateCustomerPointsRequest { - return &models.UpdateCustomerPointsRequest{ - Balance: req.Balance, - } -} - -func ListCustomerPointsRequestToModel(req *contract.ListCustomerPointsRequest) *models.ListCustomerPointsQuery { - return &models.ListCustomerPointsQuery{ - Page: req.Page, - Limit: req.Limit, - Search: req.Search, - SortBy: req.SortBy, - SortOrder: req.SortOrder, - } -} - -func CustomerPointsModelToResponse(model *models.CustomerPointsResponse) *contract.CustomerPointsResponse { - if model == nil { - return nil - } - - var customer *contract.CustomerResponse - if model.Customer != nil { - customer = CustomerModelToResponse(model.Customer) - } - - return &contract.CustomerPointsResponse{ - ID: model.ID, - CustomerID: model.CustomerID, - Balance: model.Balance, - Customer: customer, - CreatedAt: model.CreatedAt, - UpdatedAt: model.UpdatedAt, - } -} - -func PaginatedCustomerPointsResponseToContract(model *models.PaginatedCustomerPointsResponse) *contract.PaginatedCustomerPointsResponse { - if model == nil { - return nil - } - - responses := make([]contract.CustomerPointsResponse, len(model.Data)) - for i, item := range model.Data { - responses[i] = *CustomerPointsModelToResponse(&item) - } - - return &contract.PaginatedCustomerPointsResponse{ - Data: responses, - TotalCount: model.TotalCount, - Page: model.Page, - Limit: model.Limit, - TotalPages: model.TotalPages, - } -} - -// Customer Tokens Transformers -func CreateCustomerTokensRequestToModel(req *contract.CreateCustomerTokensRequest) *models.CreateCustomerTokensRequest { - return &models.CreateCustomerTokensRequest{ - CustomerID: req.CustomerID, - TokenType: req.TokenType, - Balance: req.Balance, - } -} - -func UpdateCustomerTokensRequestToModel(req *contract.UpdateCustomerTokensRequest) *models.UpdateCustomerTokensRequest { - return &models.UpdateCustomerTokensRequest{ - Balance: req.Balance, - } -} - -func ListCustomerTokensRequestToModel(req *contract.ListCustomerTokensRequest) *models.ListCustomerTokensQuery { - return &models.ListCustomerTokensQuery{ - Page: req.Page, - Limit: req.Limit, - Search: req.Search, - TokenType: req.TokenType, - SortBy: req.SortBy, - SortOrder: req.SortOrder, - } -} - -func CustomerTokensModelToResponse(model *models.CustomerTokensResponse) *contract.CustomerTokensResponse { - if model == nil { - return nil - } - - var customer *contract.CustomerResponse - if model.Customer != nil { - customer = CustomerModelToResponse(model.Customer) - } - - return &contract.CustomerTokensResponse{ - ID: model.ID, - CustomerID: model.CustomerID, - TokenType: model.TokenType, - Balance: model.Balance, - Customer: customer, - CreatedAt: model.CreatedAt, - UpdatedAt: model.UpdatedAt, - } -} - -func PaginatedCustomerTokensResponseToContract(model *models.PaginatedResponse[models.CustomerTokensResponse]) *contract.PaginatedCustomerTokensResponse { - responses := make([]contract.CustomerTokensResponse, len(model.Data)) - for i, item := range model.Data { - responses[i] = *CustomerTokensModelToResponse(&item) - } - - return &contract.PaginatedCustomerTokensResponse{ - Data: responses, - TotalCount: int(model.Pagination.Total), - Page: model.Pagination.Page, - Limit: model.Pagination.Limit, - TotalPages: model.Pagination.TotalPages, - } -} - // Tier Transformers func CreateTierRequestToModel(req *contract.CreateTierRequest) *models.CreateTierRequest { return &models.CreateTierRequest{ diff --git a/internal/validator/gamification_validator.go b/internal/validator/gamification_validator.go index 2ce5a44..e0fc023 100644 --- a/internal/validator/gamification_validator.go +++ b/internal/validator/gamification_validator.go @@ -9,20 +9,6 @@ import ( ) type GamificationValidator interface { - // Customer Points - ValidateCreateCustomerPointsRequest(req *contract.CreateCustomerPointsRequest) (error, string) - ValidateUpdateCustomerPointsRequest(req *contract.UpdateCustomerPointsRequest) (error, string) - ValidateListCustomerPointsRequest(req *contract.ListCustomerPointsRequest) (error, string) - ValidateAddCustomerPointsRequest(req *contract.AddCustomerPointsRequest) (error, string) - ValidateDeductCustomerPointsRequest(req *contract.DeductCustomerPointsRequest) (error, string) - - // Customer Tokens - ValidateCreateCustomerTokensRequest(req *contract.CreateCustomerTokensRequest) (error, string) - ValidateUpdateCustomerTokensRequest(req *contract.UpdateCustomerTokensRequest) (error, string) - ValidateListCustomerTokensRequest(req *contract.ListCustomerTokensRequest) (error, string) - ValidateAddCustomerTokensRequest(req *contract.AddCustomerTokensRequest) (error, string) - ValidateDeductCustomerTokensRequest(req *contract.DeductCustomerTokensRequest) (error, string) - // Tiers ValidateCreateTierRequest(req *contract.CreateTierRequest) (error, string) ValidateUpdateTierRequest(req *contract.UpdateTierRequest) (error, string) @@ -60,152 +46,6 @@ func NewGamificationValidator() *GamificationValidatorImpl { } } -// Customer Points Validators -func (v *GamificationValidatorImpl) ValidateCreateCustomerPointsRequest(req *contract.CreateCustomerPointsRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Balance < 0 { - return errors.New("balance cannot be negative"), "INVALID_BALANCE" - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateUpdateCustomerPointsRequest(req *contract.UpdateCustomerPointsRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Balance < 0 { - return errors.New("balance cannot be negative"), "INVALID_BALANCE" - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateListCustomerPointsRequest(req *contract.ListCustomerPointsRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Page <= 0 { - req.Page = 1 - } - if req.Limit <= 0 { - req.Limit = 10 - } - if req.Limit > 100 { - req.Limit = 100 - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateAddCustomerPointsRequest(req *contract.AddCustomerPointsRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Points <= 0 { - return errors.New("points must be greater than 0"), "INVALID_POINTS" - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateDeductCustomerPointsRequest(req *contract.DeductCustomerPointsRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Points <= 0 { - return errors.New("points must be greater than 0"), "INVALID_POINTS" - } - - return nil, "" -} - -// Customer Tokens Validators -func (v *GamificationValidatorImpl) ValidateCreateCustomerTokensRequest(req *contract.CreateCustomerTokensRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Balance < 0 { - return errors.New("balance cannot be negative"), "INVALID_BALANCE" - } - - validTokenTypes := []string{"SPIN", "RAFFLE", "MINIGAME"} - if !contains(validTokenTypes, req.TokenType) { - return errors.New("invalid token type"), "INVALID_TOKEN_TYPE" - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateUpdateCustomerTokensRequest(req *contract.UpdateCustomerTokensRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Balance < 0 { - return errors.New("balance cannot be negative"), "INVALID_BALANCE" - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateListCustomerTokensRequest(req *contract.ListCustomerTokensRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Page <= 0 { - req.Page = 1 - } - if req.Limit <= 0 { - req.Limit = 10 - } - if req.Limit > 100 { - req.Limit = 100 - } - - if req.TokenType != "" { - validTokenTypes := []string{"SPIN", "RAFFLE", "MINIGAME"} - if !contains(validTokenTypes, req.TokenType) { - return errors.New("invalid token type"), "INVALID_TOKEN_TYPE" - } - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateAddCustomerTokensRequest(req *contract.AddCustomerTokensRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Tokens <= 0 { - return errors.New("tokens must be greater than 0"), "INVALID_TOKENS" - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateDeductCustomerTokensRequest(req *contract.DeductCustomerTokensRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Tokens <= 0 { - return errors.New("tokens must be greater than 0"), "INVALID_TOKENS" - } - - return nil, "" -} - // Tier Validators func (v *GamificationValidatorImpl) ValidateCreateTierRequest(req *contract.CreateTierRequest) (error, string) { if err := v.validate.Struct(req); err != nil { -- 2.54.0 From 3cd88a55c83dd6dacc81ba118b82d4504ee1c910 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 14:46:06 +0700 Subject: [PATCH 45/47] docs(loyalty): EnakPoint & EnakCoin integration guide Adds docs/integration-enakpoint.md for the customer app, POS and dashboard teams (PC-602), in the style of the weight-based products guide. It covers the response envelope and error codes, balances and history with every ledger type, the expiring list and FCM push types with their data, the PIN flows and the four PIN error codes, paying with EnakPoint at the cashier (payment code, preview, POST /payments) and in the app, void and refund rules, exchange and transfer with Idempotency-Key, games on EnakCoin, the deprecated endpoints and fields with their replacements, the dashboard's outlet and organization settings including both expiry models, the customer wallet, adjustments, trace and PIN removal, and a checklist per team. Co-Authored-By: Claude Opus 5.5 --- docs/integration-enakpoint.md | 635 ++++++++++++++++++++++++++++++++++ 1 file changed, 635 insertions(+) create mode 100644 docs/integration-enakpoint.md diff --git a/docs/integration-enakpoint.md b/docs/integration-enakpoint.md new file mode 100644 index 0000000..1b5e816 --- /dev/null +++ b/docs/integration-enakpoint.md @@ -0,0 +1,635 @@ +# Integrasi EnakPoint & EnakCoin — Customer App, POS & Dashboard + +**Migrasi:** `000090`–`000097` · **Base URL:** `/api/v1` · **Kompatibilitas:** endpoint +lama tetap jalan sebagai alias (lihat §8) + +Panduan untuk memakai saldo loyalitas dari sisi klien. Alasan di balik setiap aturan +ada di [`prd-point-coin.md`](./prd-point-coin.md). + +--- + +## 1. Konsep inti + +| | EnakPoint (`POINT`) | EnakCoin (`COIN`) | +|---|---|---| +| Didapat dari | Order lunas (per outlet), adjustment admin, exchange | Order lunas (per outlet), adjustment admin | +| Dipakai untuk | **Membayar order** | **Main game**, ditukar ke EnakPoint | +| Bisa ditransfer | Ya | Ya | +| Bisa kedaluwarsa | Ya, bila diaktifkan owner | Ya, bila diaktifkan owner | + +Aturan yang berlaku di seluruh dokumen ini: + +1. **Semua jumlah bilangan bulat.** Tidak ada "setengah EnakPoint". +2. **Saldo tidak pernah jadi uang.** Tidak ada pencairan, tidak ada kembalian, dan + bagian order yang dibayar EnakPoint hanya bisa kembali sebagai EnakPoint. Tampilkan + nilai rupiahnya sebagai **"setara potongan Rp …"**, bukan "saldo Rp …". +3. **Semua aksi customer yang memindahkan saldo butuh PIN 6 digit** (§3): bayar, + buat kode bayar, exchange, transfer. Main game tidak butuh PIN. +4. **Wallet milik customer di satu organisasi.** Saldo berlaku di semua outlet + organisasi itu. Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan + kedaluwarsa diatur per organisasi; earning dan penerimaan pembayaran per outlet. +5. **Setiap mutasi tercatat** di riwayat beserta asal atau tujuannya, dan tidak pernah + dihapus. Koreksi muncul sebagai baris baru. + +### Format response + +Semua endpoint memakai amplop yang sama: + +```json +{ "success": true, "data": { … }, "errors": null } +``` + +```json +{ + "success": false, + "data": null, + "errors": [{ "code": "304", "entity": "wallet_service", "cause": "wallet move refused: not enough EnakCoin" }] +} +``` + +| `code` | HTTP | Arti | +|---|---|---| +| `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format | +| `304` | 400 | Permintaan ditolak aturan bisnis; `cause` menjelaskan alasannya | +| `404` | 404 | Tidak ditemukan (juga dipakai untuk data milik customer/organisasi lain) | +| `429` | 429 | Terlalu cepat meminta ulang (OTP) | +| `PIN_NOT_SET` | 403 | Customer belum membuat PIN | +| `PIN_INVALID` | 400 | PIN salah | +| `PIN_LOCKED` | 423 | PIN terkunci | +| `TRANSFER_BLOCKED` | 403 | Transfer ditahan setelah reset PIN | +| `900` | 500 | Kesalahan server | + +--- + +## 2. Customer app — saldo & riwayat + +Semua endpoint customer memakai header `Authorization: Bearer `. + +### 2.1 Saldo + +`GET /api/v1/customer/wallet` + +```json +{ + "point_balance": 12500, + "coin_balance": 8, + "point_value": 1, + "point_discount_value": 12500, + "nearest_expiring": { + "point": { "amount": 150, "date": "2026-12-31" }, + "coin": null + }, + "recent_transactions": [ … ] +} +``` + +- `point_balance` dan `coin_balance` adalah saldo yang **bisa dipakai sekarang**. +- `point_discount_value` = `point_balance × point_value`. Tampilkan sebagai + "setara potongan Rp 12.500". +- `nearest_expiring` bernilai `null` per currency bila tidak ada yang akan kedaluwarsa. +- `recent_transactions` berisi 5 mutasi terakhir dengan bentuk yang sama seperti §2.2. + +### 2.2 Riwayat + +`GET /api/v1/customer/wallet/transactions?page=1&limit=20¤cy=POINT&type=EARN,PAYMENT&from=2026-09-01&to=2026-09-30` + +Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipisah koma. +`from` / `to` tanggal WIB, inklusif. + +```json +{ + "data": [ + { + "id": "…", + "currency": "POINT", + "type": "EARN", + "amount": 875, + "balance_after": 12500, + "description": "Belanja #ORD-0123 di Outlet Kemang", + "source": { "type": "ORDER", "id": "…" }, + "outlet_id": "…", + "expires_at": "2026-12-31T23:59:59+07:00", + "lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }], + "created_at": "2026-09-30T12:01:00Z" + } + ], + "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } +} +``` + +- `amount` bertanda: positif menambah saldo, negatif mengurangi. +- Penambahan punya `source`, pengurangan punya `destination`. Keduanya berbentuk + `{ type, id }` dan menunjuk hal yang bisa dibuka di detail (order, pembayaran, game + play, dst.). +- `description` sudah siap tampil dan tidak berubah walau nama outlet atau customer + berubah belakangan. Nama lawan transfer sudah disamarkan. +- Dua baris exchange atau transfer berbagi `group_id` yang sama. + +| `type` | Arah | Arti | `source` / `destination` | +|---|---|---|---| +| `EARN` | + | Didapat dari order lunas | `ORDER` | +| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` | +| `PAYMENT` | − | Membayar order | `PAYMENT` | +| `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` | +| `EXCHANGE_OUT` / `EXCHANGE_IN` | − / + | Tukar EnakCoin ke EnakPoint | `WALLET_TX` (baris pasangannya) | +| `TRANSFER_OUT` / `TRANSFER_IN` | − / + | Transfer antar customer | `WALLET_TX` (baris pasangannya) | +| `GAME_SPEND` | − | Main game | `GAME_PLAY` | +| `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` | +| `ADJUSTMENT` | + / − | Koreksi oleh admin | `USER` | +| `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` | + +### 2.3 Yang akan kedaluwarsa + +`GET /api/v1/customer/wallet/expiring` + +```json +{ + "point": [ + { "amount": 150, "date": "2026-10-31" }, + { "amount": 200, "date": "2026-12-31" } + ], + "coin": [] +} +``` + +Dikelompokkan per tanggal (WIB), paling dekat lebih dulu. Saldo bisa dipakai sampai +akhir hari tanggal itu. Daftar kosong berarti tidak ada yang akan kedaluwarsa. + +### 2.4 Notifikasi push (FCM) + +Aplikasi mendaftarkan token FCM-nya **setelah login dan setiap kali FCM memberi token +baru**: + +`PUT /api/v1/customer/devices` + +```json +{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" } +``` + +`platform`: `android`, `ios`, atau `web` (opsional). Saat logout, panggil +`DELETE /api/v1/customer/devices/:device_id` supaya HP itu tidak lagi menerima +notifikasi customer tersebut. Satu token hanya milik satu customer: bila customer lain +login di HP yang sama dan mendaftarkan token yang sama, customer sebelumnya otomatis +tidak menerima notifikasi di HP itu lagi. + +Push yang dikirim, dibedakan lewat `data.type`: + +| `data.type` | Kapan | Isi `data` lainnya | +|---|---|---| +| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` | +| `WALLET_EXPIRING` | `reminder_days` hari sebelum saldo kedaluwarsa, sekali per tanggal | `currency`, `amount`, `expiry_date` | +| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` | +| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339, UTC) | + +Semua nilai di `data` berupa string, sesuai aturan FCM. + +--- + +## 3. Customer app — PIN + +PIN 6 digit, terpisah dari password login, dikirim sebagai **string** supaya angka nol +di depan tidak hilang. PIN tidak pernah dikembalikan di response. + +### 3.1 Cek status + +`GET /api/v1/customer/pin/status` + +```json +{ "has_pin": true, "locked_until": null, "transfer_blocked_until": null } +``` + +Minta customer membuat PIN saat pertama kali ia melakukan aksi yang butuh PIN +(`has_pin: false`), bukan saat registrasi. + +### 3.2 Membuat PIN pertama kali + +1. `POST /api/v1/customer/pin/otp` dengan `{ "purpose": "pin_setup" }`. OTP dikirim ke + nomor customer lewat WhatsApp. Response: `{ "purpose", "otp_token", "expires_at" }`. +2. `POST /api/v1/customer/pin` dengan + `{ "otp_token": "…", "otp_code": "123456", "pin": "482913", "confirm_pin": "482913" }`. + +PIN ditolak (`304`) bila bukan 6 digit, konfirmasinya beda, semua digit sama +(`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir +(`DDMMYY` / `YYMMDD`). Tampilkan `cause` apa adanya. Meminta OTP terlalu cepat +menghasilkan `429`. + +### 3.3 Mengganti dan mereset PIN + +- **Ganti:** `PUT /api/v1/customer/pin` dengan `{ "old_pin", "pin", "confirm_pin" }`. +- **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu + `POST /api/v1/customer/pin/reset` dengan body yang sama seperti §3.2. Reset juga + membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**; + pembayaran dan exchange tetap bisa. + +### 3.4 Menangani error PIN + +Setiap endpoint yang menerima `pin` bisa mengembalikan error PIN. Pada error ini `data` +**tidak** `null`: + +```json +{ + "success": false, + "data": { "code": "PIN_INVALID", "remaining_attempts": 3 }, + "errors": [{ "code": "PIN_INVALID", "entity": "customer_pin_service", "cause": "wrong PIN, 3 attempts left" }] +} +``` + +| `data.code` | Field tambahan | Yang ditampilkan aplikasi | +|---|---|---| +| `PIN_NOT_SET` | – | Arahkan ke pembuatan PIN (§3.2) | +| `PIN_INVALID` | `remaining_attempts` | "PIN salah, sisa 3 percobaan" | +| `PIN_LOCKED` | `locked_until` | "PIN terkunci sampai 14:30", tawarkan reset PIN | +| `TRANSFER_BLOCKED` | `transfer_blocked_until` | "Transfer bisa dilakukan lagi pada …" | + +Lima kali salah berturut-turut mengunci PIN selama 30 menit. Selama terkunci, PIN yang +benar pun ditolak. Penghitung disimpan di server, jadi tidak bisa diakali dengan +reinstall atau ganti HP. + +--- + +## 4. Membayar dengan EnakPoint + +Ada dua jalur. Keduanya memakai logika perhitungan yang sama. + +### 4.1 Batas pembayaran + +EnakPoint maksimal yang bisa dipakai untuk satu order: + +``` +batas_rupiah = min(sisa_tagihan, total_order × max_payment_percent / 100 − yang_sudah_dibayar_EnakPoint) +maks_point = min(saldo_customer, floor(batas_rupiah / point_value)) +``` + +Ditambah minimal `min_payment_points` per pembayaran. Nominal rupiah pembayaran selalu +`points × point_value` dan **tidak pernah melebihi sisa tagihan**, jadi tidak ada +kembalian. Sisa tagihan dibayar dengan method lain seperti biasa (split). + +### 4.2 POS — kode bayar dari aplikasi customer + +PIN **tidak pernah** diketik di perangkat kasir. Customer menyetujui di HP-nya sendiri: + +1. **Customer app:** `POST /api/v1/customer/wallet/payment-code` dengan `{ "pin": "482913" }`. + + ```json + { "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" } + ``` + + Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR. Kode berlaku **2 menit**, + sekali pakai, dan hanya untuk customer itu. Membuat kode baru membatalkan kode lama. + +2. **POS:** tampilkan batas untuk tombol "pakai maksimal": + + `GET /api/v1/orders/:id/point-payment/preview` + + ```json + { + "order_id": "…", + "customer_id": "…", + "eligible": true, + "point_balance": 12500, + "point_value": 1, + "remaining_amount": 87500, + "min_payment_points": 1, + "max_payment_percent": 100, + "max_points": 12500, + "max_amount": 12500 + } + ``` + + Bila `eligible: false`, `reason` menjelaskan kenapa (order walk-in, outlet tidak + menerima EnakPoint, saldo di bawah minimal, dst.). + +3. **POS:** bayar lewat endpoint pembayaran yang sudah ada, dengan payment method + bertipe `point`: + + `POST /api/v1/payments` (header `X-Idempotency-Key` wajib seperti pembayaran lain) + + ```json + { + "order_id": "…", + "payment_method_id": "", + "points": 12500, + "payment_code": "482913" + } + ``` + + `amount` tidak perlu dikirim; backend menghitungnya. `payment_code` boleh berupa + angka yang diketik kasir atau hasil scan QR apa adanya (`enakpoint:482913`). + +Response pembayaran membawa `points_used` dan `point_value` untuk struk, misalnya +"EnakPoint: 12.500 (Rp 12.500)". Jika pembayaran ini melunasi order, order menjadi +`completed`; jika belum, sisanya dibayar dengan method lain. + +Pembayaran ditolak (`304`, `cause` menjelaskan) bila: order tanpa customer atau +customer walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar +batas §4.1, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method +EnakPoint dipakai sebagai split (bayar bagian EnakPoint sebagai pembayaran tersendiri, +lalu split sisanya seperti biasa). Kode bayar dipakai habis begitu diterima, sebelum +batas dicek ulang; bila pembayaran lalu ditolak (misalnya saldo berubah), minta +customer membuat kode baru. + +**Method EnakPoint** dibuat otomatis untuk setiap organisasi dan tidak bisa dihapus +atau diubah tipenya (namanya boleh diganti). Daftar payment method yang dikirim +`?outlet_id=` tidak menampilkannya bila outlet itu tidak menerima EnakPoint. + +### 4.3 Customer app / self-order — bayar order sendiri + +`POST /api/v1/customer/orders/:id/pay-with-points` + +```json +{ "points": 12500, "pin": "482913" } +``` + +Hanya untuk order milik customer yang login; order lain dijawab `404`. Response sama +dengan response pembayaran di §4.2. + +### 4.4 Void dan refund + +- **Void order:** semua EnakPoint yang dipakai kembali ke customer sebagai EnakPoint. +- **Refund pembayaran EnakPoint** (`POST /api/v1/payments/:id/refund` pada pembayaran + EnakPoint): yang kembali `floor(rupiah_direfund / point_value_saat_bayar)`. Perubahan + nilai EnakPoint setelah pembayaran tidak mengubah jumlah yang kembali; sisa di bawah + 1 EnakPoint hangus. +- **Refund order ke tunai / method lain** hanya boleh sebesar bagian yang dibayar + dengan method lain. Bagian EnakPoint harus direfund lewat pembayaran EnakPoint-nya + sendiri; mencoba lewat tunai dijawab `304`. +- EnakPoint yang kembali mengikuti tanggal kedaluwarsa asalnya, tapi minimal 7 hari + sejak refund. +- EnakPoint dan EnakCoin yang didapat dari order ikut ditarik saat void/refund. Bila + saldo customer sudah terpakai, yang ditarik sebanyak yang ada; refund tidak pernah + diblokir karena ini. + +### 4.5 Earning di layar order dan struk + +Response order membawa `points_earned` dan `coins_earned` (0 bila order tidak +menghasilkan apa-apa). Earning dihitung dari `subtotal − discount − bagian yang +dibayar EnakPoint`, sebelum pajak, dan diberikan saat order lunas. + +--- + +## 5. Exchange EnakCoin → EnakPoint + +Kurs per organisasi: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1). + +1. **Preview** sebelum minta PIN: + + `GET /api/v1/customer/wallet/exchange/preview?coins=30` + + ```json + { "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true } + ``` + + Bila `valid: false`, tampilkan `reason` (misalnya harus kelipatan `coin_amount`, + atau EnakCoin tidak cukup). + +2. **Tukar:** + + `POST /api/v1/customer/wallet/exchange` dengan header **`Idempotency-Key`** (wajib, + maks. 50 karakter, satu key per percobaan tukar) + + ```json + { "coins": 30, "pin": "482913" } + ``` + + ```json + { + "group_id": "…", + "coins": 30, + "points": 9, + "coin_amount": 10, + "point_amount": 3, + "lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }], + "coin_balance": 5, + "point_balance": 9, + "replayed": false + } + ``` + +- Jumlah EnakCoin harus kelipatan `coin_amount`. Kesalahan jumlah ditolak **sebelum** + PIN dicek, jadi tidak memakan jatah percobaan PIN. +- Exchange tidak bisa dibatalkan; tampilkan konfirmasi. +- Kirim ulang dengan `Idempotency-Key` yang sama bila koneksi putus: hasil pertama + dikembalikan dengan `replayed: true` tanpa menukar lagi, dengan kurs saat itu. + `Idempotency-Key` yang sama untuk jumlah berbeda ditolak. +- EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (`lots` + menunjukkan tanggalnya). + +--- + +## 6. Transfer ke customer lain + +1. **Cek penerima** sebelum konfirmasi: + + `GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234` + + ```json + { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" } + ``` + + Nomor yang tidak terdaftar di organisasi yang sama dijawab `404`. Diri sendiri, + customer walk-in, atau customer nonaktif dijawab `304`. + +2. **Kirim:** + + `POST /api/v1/customer/wallet/transfer` dengan header **`Idempotency-Key`** (wajib) + + ```json + { "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" } + ``` + + ```json + { + "group_id": "…", + "currency": "POINT", + "amount": 120, + "recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }, + "lots": [ + { "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" }, + { "amount": 20, "expires_at": null } + ], + "balance": 30, + "replayed": false + } + ``` + +- `currency`: `POINT` atau `COIN`, satu jenis per transfer. +- Batas dari organisasi: transfer bisa dimatikan, ada minimal, maksimal per + transaksi, dan batas harian per currency (reset tengah malam WIB). Pelanggaran batas + ditolak `304` sebelum PIN dicek. +- Transfer final dan tidak bisa dibatalkan customer. +- Saldo yang dikirim membawa tanggal kedaluwarsa aslinya ke penerima (`lots`). + Tampilkan ini ke pengirim. +- Penerima mendapat push `WALLET_TRANSFER_IN` (§2.4). +- Retry dengan `Idempotency-Key` yang sama mengembalikan hasil pertama + (`replayed: true`) dan tidak dihitung dua kali terhadap batas harian. + +--- + +## 7. Game + +`POST /api/v1/customer/spin` dengan `{ "spin_id": "" }`. Tanpa PIN. + +Setiap game memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1). +Response: + +```json +{ + "game_play": { "id": "…", "game_id": "…", "coins_used": 1, "token_used": 1, "created_at": "…" }, + "prize_won": { "id": "…", "name": "Voucher 10rb", … }, + "coins_remaining": 7, + "tokens_remaining": 7 +} +``` + +EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada +EnakCoin yang terpotong. Baca `coins_used` dan `coins_remaining`; `token_used` dan +`tokens_remaining` hanya salinan untuk versi aplikasi lama. + +Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1. + +--- + +## 8. Endpoint lama (deprecated) + +Masih jalan dan membaca saldo wallet, tapi akan dihapus setelah semua versi aplikasi +pindah. Aplikasi baru jangan memakainya. + +| Lama | Ganti dengan | +|---|---| +| `GET /customer/points` | `GET /customer/wallet` (`point_balance`) | +| `GET /customer/tokens` | `GET /customer/wallet` (`coin_balance`) | +| `total_points`, `total_tokens`, `points_history`, `tokens_history`, `last_updated` di `/customer/wallet` | `point_balance`, `coin_balance`, `recent_transactions` | +| `token_used`, `tokens_remaining` di respons game | `coins_used`, `coins_remaining` | +| `sort_by=token_used` di daftar game play | `sort_by=coins_used` | + +Beri tahu tim backend setelah aplikasi yang beredar tidak lagi memakai kolom kiri, +supaya alias dan tabel lama (`customer_points`, `customer_tokens`) bisa dihapus. + +--- + +## 9. Dashboard + +Semua endpoint di bagian ini butuh login user dengan role Admin atau Manager. + +### 9.1 Pengaturan per outlet + +`GET` / `PUT /api/v1/outlets/:outlet_id/loyalty-settings` + +```json +{ + "point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null }, + "coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null }, + "point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 } +} +``` + +Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan +`point_value` organisasi dan `point_cashback_percent` +(`earn_value × point_value / earn_per_amount × 100`). **Tampilkan persentase ini di +samping setting** supaya owner tidak salah membaca skala: default di atas setara +cashback 1%. + +### 9.2 Pengaturan organisasi + +`GET` / `PUT /api/v1/marketing/loyalty-settings` (tambah `?dry_run=true` untuk preview +tanpa menyimpan) + +```json +{ + "point_value": 1, + "exchange": { "coin_amount": 1, "point_amount": 1 }, + "transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null }, + "point_expiry": { + "enabled": false, + "mode": "FIXED_DATE", + "fixed_dates": ["12-31"], + "grace_months": 3, + "period": 12, + "unit": "MONTH", + "end_of_month": false, + "reminder_days": 7 + }, + "coin_expiry": { … sama … } +} +``` + +Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan: + +- `impact`: total saldo beredar dan nilai rupiahnya **sebelum dan sesudah** perubahan + `point_value` atau kurs. Tampilkan sebagai peringatan sebelum owner menyimpan. +- `expiry_preview`: `{ "point": …, "coin": … }`, kapan saldo yang didapat hari ini + akan kedaluwarsa (`null` bila tidak kedaluwarsa). Tampilkan sebagai "EnakPoint yang + didapat hari ini kedaluwarsa pada 31 Des 2026". +- `expiry_activations`: bila perubahan ini **menyalakan** kedaluwarsa untuk pertama + kali, berapa saldo lama yang ikut diberi tanggal (`lots`, `amount`) dan tanggalnya + (`expires_at`). Selalu minta konfirmasi dengan `dry_run=true` dulu. +- `changes`: key yang berubah. + +**Kedaluwarsa** diatur per currency dengan salah satu model: + +| `mode` | Cara kerja | Field yang dipakai | +|---|---|---| +| `FIXED_DATE` (default) | Semua saldo hangus di tanggal tetap setiap tahun. Saldo yang didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya | `fixed_dates` (format `MM-DD`, boleh lebih dari satu, `02-29` ditolak), `grace_months` (0–24) | +| `ROLLING` | Tiap saldo berlaku sekian lama sejak didapat | `period`, `unit` (`DAY` / `MONTH`), `end_of_month` | + +- `reminder_days` berlaku untuk keduanya: customer diingatkan sekian hari sebelum + hangus (0 = tanpa pengingat). +- Mengubah pengaturan hanya berlaku untuk saldo yang masuk setelahnya. +- Menyalakan kedaluwarsa pertama kali memberi saldo lama masa berlaku penuh: tanggal + hangus kedua berikutnya (`FIXED_DATE`) atau satu periode penuh (`ROLLING`). +- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal. + +Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit=20` +(tambah `outlet_id=` untuk setting outlet). + +### 9.3 Wallet customer + +- `GET /api/v1/marketing/customers/:id/wallet` — saldo buku dan saldo yang bisa + dipakai, semua lot yang masih berisi, dan riwayat dengan nama asli (lawan transfer, + admin, kasir, outlet). Query riwayat sama seperti §2.2. +- `POST /api/v1/marketing/customers/:id/wallet/adjust` + + ```json + { "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" } + ``` + + `amount` bertanda. `reason` wajib. Pengurangan yang melebihi saldo ditolak. + Adjustment tidak disertai pembayaran uang, jadi jangan pakai alasan "pencairan". + +- `GET /api/v1/marketing/wallet-transactions/:id/trace` — telusuri satu mutasi per + butir: lot mana yang dipakai atau dibuat, lalu rantai asalnya lewat transfer, + exchange, atau refund sampai ke earning/adjustment/migrasi pertama. Contoh: dari + pembayaran B bisa terlihat bahwa EnakPoint-nya berasal dari order #ORD-1 milik A + yang mentransfer ke B. + +### 9.4 PIN customer + +- `DELETE /api/v1/marketing/customers/:id/pin` dengan `{ "reason": "…" }` — hapus PIN + bila customer kehilangan akses. Customer lalu membuat PIN baru lewat OTP. Admin + **tidak bisa** membuat, mengganti, atau melihat PIN. +- `GET /api/v1/marketing/customers/:id/security-events?page=1&limit=20` — log keamanan: + `PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`, + `PIN_REMOVED_BY_ADMIN`, beserta waktu, IP, dan perangkat. + +--- + +## 10. Checklist integrasi + +**Customer app** +- [ ] Daftarkan token FCM setelah login dan saat token berganti; hapus saat logout. +- [ ] Tangani empat kode error PIN (§3.4) di semua layar yang meminta PIN. +- [ ] Kirim `Idempotency-Key` baru untuk setiap exchange dan transfer, dan pakai ulang + key yang sama saat retry. +- [ ] Tampilkan nilai rupiah sebagai "setara potongan", bukan saldo uang. +- [ ] Baca `coins_used` / `coins_remaining` dan `/customer/wallet`, bukan field lama. + +**POS** +- [ ] Scan QR atau ketik kode bayar, jangan pernah meminta PIN customer di layar kasir. +- [ ] Pakai `point-payment/preview` untuk tombol "pakai maksimal". +- [ ] Cetak `points_used`, `points_earned`, dan `coins_earned` di struk. +- [ ] Refund bagian EnakPoint lewat pembayaran EnakPoint-nya, bukan tunai. + +**Dashboard** +- [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan + `expiry_activations` sebelum owner menyimpan setting. +- [ ] Isi `metadata.coin_cost` untuk setiap game. -- 2.54.0 From ae8003c1e43e731a8821abb2ee150c16118af9c0 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 15:01:02 +0700 Subject: [PATCH 46/47] docs(loyalty): API reference and backoffice guide for EnakPoint & EnakCoin Adds docs/api-enakpoint.md, the endpoint reference for the customer app, POS and dashboard, and docs/backoffice-enakpoint.md, the screens the backoffice needs: outlet and organization settings with the save flow and impact dialog, both expiry models, the customer wallet with adjustment and trace, PIN removal and security log, settings history, game coin_cost and the EnakPoint payment method. Co-Authored-By: Claude Opus 5.5 --- docs/api-enakpoint.md | 439 +++++++++++++++++++++++++++++++++++ docs/backoffice-enakpoint.md | 305 ++++++++++++++++++++++++ 2 files changed, 744 insertions(+) create mode 100644 docs/api-enakpoint.md create mode 100644 docs/backoffice-enakpoint.md diff --git a/docs/api-enakpoint.md b/docs/api-enakpoint.md new file mode 100644 index 0000000..8bd3837 --- /dev/null +++ b/docs/api-enakpoint.md @@ -0,0 +1,439 @@ +# API EnakPoint & EnakCoin + +30 Sep 2026 + +Semua endpoint EnakPoint (`POINT`, bisa bayar order) dan EnakCoin (`COIN`, untuk game dan ditukar ke EnakPoint) ada di bawah base URL `/api/v1`, memakai satu format response, dan semua jumlah berupa bilangan bulat. + +## Konvensi umum + +| Klien | Autentikasi | Prefix | +| --- | --- | --- | +| Customer app / self-order | `Authorization: Bearer ` | `/api/v1/customer` | +| POS | Token user (kasir/manager) | `/api/v1` | +| Dashboard | Token user, role Admin atau Manager | `/api/v1/marketing`, `/api/v1/outlets` | + +**Format response.** Sukses: `{"success": true, "data": {…}, "errors": null}`. Gagal: `{"success": false, "data": null, "errors": [{"code": "304", "entity": "wallet_service", "cause": "…"}]}`. Tampilkan `cause` sebagai alasan penolakan. + +| `code` | HTTP | Arti | +| --- | --- | --- | +| `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format | +| `304` | 400 | Ditolak aturan bisnis (saldo kurang, di luar batas, dst.) | +| `404` | 404 | Tidak ditemukan, juga untuk data milik customer atau organisasi lain | +| `429` | 429 | OTP diminta ulang terlalu cepat | +| `PIN_NOT_SET` | 403 | Customer belum membuat PIN | +| `PIN_INVALID` | 400 | PIN salah | +| `PIN_LOCKED` | 423 | PIN terkunci 30 menit setelah 5 kali salah | +| `TRANSFER_BLOCKED` | 403 | Transfer ditahan 24 jam setelah reset PIN | +| `900` | 500 | Kesalahan server | + +**Error PIN** membawa `data` yang tidak `null`: `{"code": "PIN_INVALID", "remaining_attempts": 3}`, `{"code": "PIN_LOCKED", "locked_until": "…"}`, atau `{"code": "TRANSFER_BLOCKED", "transfer_blocked_until": "…"}`. Endpoint yang menerima `pin` bisa mengembalikan salah satunya. PIN selalu dikirim sebagai string 6 digit. + +**Idempotency.** Exchange dan transfer wajib header `Idempotency-Key` (maks. 50 karakter, `X-Idempotency-Key` juga diterima): satu key per percobaan, dan key yang sama dipakai ulang saat retry. Retry mengembalikan hasil pertama dengan `replayed: true`. `POST /payments` wajib `X-Idempotency-Key` seperti pembayaran lain. + +**Waktu.** Tanggal kedaluwarsa dan filter tanggal memakai WIB. Saldo berlaku sampai 23:59:59 WIB pada tanggal kedaluwarsanya. + +## Customer app: saldo & riwayat + +| Method | Path | Keterangan | +| --- | --- | --- | +| GET | `/customer/wallet` | Saldo, nilai rupiah, kedaluwarsa terdekat, 5 mutasi terakhir | +| GET | `/customer/wallet/transactions` | Riwayat mutasi, dengan pagination dan filter | +| GET | `/customer/wallet/expiring` | Saldo yang akan kedaluwarsa, per currency dan tanggal | +| PUT | `/customer/devices` | Daftarkan token FCM device | +| DELETE | `/customer/devices/:device_id` | Hapus device saat logout | + +### GET /customer/wallet + +```json +{ + "point_balance": 12500, + "coin_balance": 8, + "point_value": 1, + "point_discount_value": 12500, + "nearest_expiring": { + "point": { "amount": 150, "date": "2026-12-31" }, + "coin": null + }, + "recent_transactions": [ "… sama seperti item riwayat …" ] +} +``` + +- `point_balance` / `coin_balance` = saldo yang bisa dipakai sekarang. +- `point_discount_value` = `point_balance × point_value`; tampilkan sebagai "setara potongan Rp …", bukan saldo uang. +- `nearest_expiring.point` / `.coin` bernilai `null` bila tidak ada yang akan kedaluwarsa. + +### GET /customer/wallet/transactions + +| Query | Tipe | Keterangan | +| --- | --- | --- | +| `page` | int | Default 1 | +| `limit` | int | 1–100, default 20 | +| `currency` | `POINT` \| `COIN` | Opsional | +| `type` | string | Satu tipe atau beberapa dipisah koma, mis. `EARN,PAYMENT` | +| `from`, `to` | `YYYY-MM-DD` | Tanggal WIB, inklusif | + +```json +{ + "data": [ + { + "id": "…", + "currency": "POINT", + "type": "EARN", + "amount": 875, + "balance_after": 12500, + "description": "Belanja #ORD-0123 di Outlet Kemang", + "source": { "type": "ORDER", "id": "…" }, + "outlet_id": "…", + "group_id": null, + "expires_at": "2026-12-31T23:59:59+07:00", + "lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }], + "created_at": "2026-09-30T12:01:00Z" + } + ], + "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } +} +``` + +`amount` bertanda (+ menambah, − mengurangi). Penambahan membawa `source`, pengurangan membawa `destination`, keduanya `{ type, id }`. Dua baris exchange atau transfer berbagi `group_id`. Daftar tipe ada di bagian Referensi. + +### GET /customer/wallet/expiring + +```json +{ + "point": [ + { "amount": 150, "date": "2026-10-31" }, + { "amount": 200, "date": "2026-12-31" } + ], + "coin": [] +} +``` + +Terurut dari tanggal terdekat. Daftar kosong berarti tidak ada yang akan kedaluwarsa. + +### PUT /customer/devices + +```json +{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" } +``` + +Panggil setelah login dan setiap kali FCM memberi token baru. `device_id` dan `fcm_token` wajib; `platform` = `android` | `ios` | `web`. Satu token hanya milik satu customer: customer lain yang mendaftarkan token yang sama mengambil alih HP itu. Response: `{ "device_id": "a1b2c3" }`. + +## Customer app: PIN + +PIN 6 digit wajib untuk bayar, kode bayar, exchange, dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu. + +| Method | Path | Body | Response | +| --- | --- | --- | --- | +| GET | `/customer/pin/status` | – | `{ "has_pin", "locked_until", "transfer_blocked_until" }` | +| POST | `/customer/pin/otp` | `{ "purpose": "pin_setup" }` atau `"pin_reset"` | `{ "purpose", "otp_token", "expires_at" }` | +| POST | `/customer/pin` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN | +| PUT | `/customer/pin` | `{ "old_pin", "pin", "confirm_pin" }` | Status PIN | +| POST | `/customer/pin/reset` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN | + +1. **Buat PIN:** minta OTP dengan `purpose: "pin_setup"` (dikirim lewat WhatsApp), lalu `POST /customer/pin` dengan `otp_token` dari response OTP dan kode yang diterima customer. +2. **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu `POST /customer/pin/reset`. Reset membuka kunci PIN, tapi transfer keluar ditahan 24 jam; pembayaran dan exchange tetap bisa. +3. **Ganti PIN:** `PUT /customer/pin` dengan PIN lama. + +PIN baru ditolak `304` bila bukan 6 digit, konfirmasinya beda, semua digit sama (`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir (`DDMMYY` / `YYMMDD`). OTP yang diminta terlalu cepat dijawab `429`. Penanganan `PIN_INVALID`, `PIN_LOCKED`, dan `TRANSFER_BLOCKED` ada di Konvensi umum. + +## Customer app: bayar, exchange, transfer, game + +| Method | Path | PIN | Idempotency-Key | +| --- | --- | --- | --- | +| POST | `/customer/wallet/payment-code` | Ya | – | +| POST | `/customer/orders/:id/pay-with-points` | Ya | – | +| GET | `/customer/wallet/exchange/preview?coins=` | – | – | +| POST | `/customer/wallet/exchange` | Ya | Wajib | +| GET | `/customer/wallet/transfer/recipient?phone=` | – | – | +| POST | `/customer/wallet/transfer` | Ya | Wajib | +| POST | `/customer/spin` | – | – | + +### POST /customer/wallet/payment-code + +Body `{ "pin": "482913" }`. Response: + +```json +{ "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" } +``` + +Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR untuk kasir. Berlaku 2 menit, sekali pakai, hanya untuk customer ini; kode baru membatalkan kode lama. + +### POST /customer/orders/:id/pay-with-points + +Body `{ "points": 12500, "pin": "482913" }`. Hanya untuk order milik customer yang login (order lain `404`). Response sama dengan pembayaran POS (bagian POS). Batas dan aturan penolakan juga sama. + +### GET /customer/wallet/exchange/preview?coins=30 + +```json +{ "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true } +``` + +Kurs: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1). Bila `valid: false`, tampilkan `reason`. + +### POST /customer/wallet/exchange + +Body `{ "coins": 30, "pin": "482913" }`. Response: + +```json +{ + "group_id": "…", + "coins": 30, + "points": 9, + "coin_amount": 10, + "point_amount": 3, + "lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }], + "coin_balance": 5, + "point_balance": 9, + "replayed": false +} +``` + +`coins` harus kelipatan `coin_amount`; jumlah yang salah ditolak `304` sebelum PIN dicek. Exchange tidak bisa dibatalkan. EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (lihat `lots`). + +### GET /customer/wallet/transfer/recipient?phone=081234561234 + +```json +{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" } +``` + +Nomor di luar organisasi atau tidak terdaftar → `404`. Diri sendiri, customer walk-in, atau nonaktif → `304`. + +### POST /customer/wallet/transfer + +Body `{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" }`. Response: + +```json +{ + "group_id": "…", + "currency": "POINT", + "amount": 120, + "recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }, + "lots": [ + { "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" }, + { "amount": 20, "expires_at": null } + ], + "balance": 30, + "replayed": false +} +``` + +`currency` = `POINT` atau `COIN`. Batas organisasi (transfer aktif, minimal, maksimal per transaksi, batas harian per currency yang reset tengah malam WIB) ditolak `304` sebelum PIN dicek. Transfer final. Saldo membawa tanggal kedaluwarsa aslinya ke penerima (`lots`), dan penerima mendapat push `WALLET_TRANSFER_IN`. + +### POST /customer/spin + +Body `{ "spin_id": "" }`. Memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1). + +```json +{ + "game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" }, + "prize_won": { "id": "…", "name": "Voucher 10rb" }, + "coins_remaining": 7 +} +``` + +EnakCoin kurang, game nonaktif, atau hadiah baru saja habis → `304`, tidak ada EnakCoin yang terpotong. + +## POS: pembayaran EnakPoint + +Kasir memakai endpoint pembayaran yang sudah ada dengan payment method bertipe `point`, disetujui customer lewat kode bayar dari aplikasinya; PIN tidak pernah diketik di perangkat kasir. + +| Method | Path | Keterangan | +| --- | --- | --- | +| GET | `/orders/:id/point-payment/preview` | Batas pembayaran EnakPoint untuk order ini | +| POST | `/payments` | Bayar dengan method EnakPoint (`points` + `payment_code`) | +| POST | `/payments/:id/refund` | Refund pembayaran EnakPoint, kembali sebagai EnakPoint | + +1. Customer membuat kode di aplikasi (`POST /customer/wallet/payment-code`) dan menunjukkan angka atau QR-nya. +2. POS memanggil preview untuk tombol "pakai maksimal". +3. POS memanggil `POST /payments` dengan kode tersebut. Sisa tagihan dibayar dengan method lain seperti biasa. + +### GET /orders/:id/point-payment/preview + +```json +{ + "order_id": "…", + "customer_id": "…", + "eligible": true, + "point_balance": 12500, + "point_value": 1, + "remaining_amount": 87500, + "min_payment_points": 1, + "max_payment_percent": 100, + "max_points": 12500, + "max_amount": 12500 +} +``` + +Bila `eligible: false`, `reason` menjelaskan kenapa (order walk-in, outlet tidak menerima EnakPoint, saldo di bawah minimal, dst.). Batas yang dipakai: + +``` +batas_rupiah = min(sisa_tagihan, total × max_payment_percent / 100 − sudah_dibayar_EnakPoint) +maks_point = min(saldo, floor(batas_rupiah / point_value)) +``` + +### POST /payments + +Header `X-Idempotency-Key` wajib. + +```json +{ + "order_id": "…", + "payment_method_id": "", + "points": 12500, + "payment_code": "482913" +} +``` + +- `amount` tidak perlu dikirim; backend menghitung `points × point_value` dan tidak pernah melebihi sisa tagihan (tidak ada kembalian). +- `payment_code` boleh angka yang diketik atau hasil scan QR apa adanya (`enakpoint:482913`). +- Response pembayaran membawa `points_used` dan `point_value` untuk struk; response order membawa `points_earned` dan `coins_earned`. +- Ditolak `304` bila: order tanpa customer atau walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar batas, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method EnakPoint dipakai sebagai split. Kode terpakai begitu diterima; bila pembayaran lalu ditolak, minta kode baru. +- Method EnakPoint dibuat otomatis per organisasi, tidak bisa dihapus atau diubah tipenya, dan tidak muncul di daftar method `?outlet_id=` bila outlet tidak menerima EnakPoint. + +### Void dan refund + +- **Void order:** semua EnakPoint yang dipakai kembali sebagai EnakPoint. +- **`POST /payments/:id/refund` pada pembayaran EnakPoint:** kembali `floor(rupiah_direfund / point_value_saat_bayar)`; sisa di bawah 1 EnakPoint hangus. +- **Refund order ke tunai/method lain** hanya sebesar bagian non-EnakPoint; mencoba merefund bagian EnakPoint secara tunai ditolak `304`. +- EnakPoint yang kembali memakai tanggal kedaluwarsa asal, minimal 7 hari sejak refund. Earning order ikut ditarik; bila saldo sudah terpakai, ditarik sebanyak yang ada dan refund tetap jalan. + +## Dashboard + +Semua endpoint dashboard butuh role Admin atau Manager, dan semuanya dibatasi ke organisasi user yang login. Rincian layar ada di [`backoffice-enakpoint.md`](./backoffice-enakpoint.md). + +| Method | Path | Keterangan | +| --- | --- | --- | +| GET, PUT | `/outlets/:outlet_id/loyalty-settings` | Earning dan penerimaan EnakPoint per outlet | +| GET, PUT | `/marketing/loyalty-settings` | Nilai EnakPoint, kurs, transfer, kedaluwarsa (`?dry_run=true` untuk preview) | +| GET | `/marketing/loyalty-settings/history` | Riwayat perubahan setting (`page`, `limit`, `outlet_id`) | +| GET | `/marketing/customers/:id/wallet` | Saldo, lot aktif, riwayat dengan nama asli | +| POST | `/marketing/customers/:id/wallet/adjust` | Koreksi saldo manual | +| GET | `/marketing/wallet-transactions/:id/trace` | Telusuri asal saldo per butir | +| DELETE | `/marketing/customers/:id/pin` | Hapus PIN customer | +| GET | `/marketing/customers/:id/security-events` | Log keamanan PIN (`page`, `limit`) | + +Pada kedua `PUT` setting, field yang tidak dikirim tetap memakai nilai sekarang; field yang tidak dikenal ditolak. + +### /outlets/:outlet_id/loyalty-settings + +```json +{ + "point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null }, + "coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null }, + "point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 } +} +``` + +Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `max_payment_percent` 0–100. + +### /marketing/loyalty-settings + +```json +{ + "point_value": 1, + "exchange": { "coin_amount": 1, "point_amount": 1 }, + "transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null }, + "point_expiry": { + "enabled": false, + "mode": "FIXED_DATE", + "fixed_dates": ["12-31"], + "grace_months": 3, + "period": 12, + "unit": "MONTH", + "end_of_month": false, + "reminder_days": 7 + }, + "coin_expiry": { "…": "sama dengan point_expiry" } +} +``` + +| Field kedaluwarsa | Dipakai mode | Nilai | +| --- | --- | --- | +| `mode` | – | `FIXED_DATE` (hangus di tanggal tetap tiap tahun) atau `ROLLING` (umur sejak didapat) | +| `fixed_dates` | `FIXED_DATE` | `MM-DD`, boleh lebih dari satu; `02-29` ditolak | +| `grace_months` | `FIXED_DATE` | 0–24; saldo yang didapat kurang dari ini sebelum tanggal hangus ikut ke tanggal berikutnya | +| `period`, `unit` | `ROLLING` | ≥ 1, `DAY` atau `MONTH` | +| `end_of_month` | `ROLLING` | Dibulatkan ke akhir bulan | +| `reminder_days` | keduanya | Hari sebelum hangus untuk pengingat; 0 = tanpa pengingat | + +Response menambahkan: + +- `impact`: saldo beredar dan nilai rupiahnya sebelum/sesudah perubahan `point_value` atau kurs. +- `expiry_preview`: `{ "point", "coin" }`, kapan saldo yang didapat sekarang kedaluwarsa (`null` = tidak). +- `expiry_activations`: bila perubahan ini menyalakan kedaluwarsa pertama kali, `[{ "currency", "lots", "amount", "expires_at" }]` saldo lama yang ikut diberi tanggal. +- `changes` dan `dry_run`. + +### POST /marketing/customers/:id/wallet/adjust + +```json +{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" } +``` + +`amount` bertanda dan tidak boleh 0; `reason` wajib. Pengurangan yang melebihi saldo ditolak `304`. Response: `{ "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }`. + +### GET /marketing/wallet-transactions/:id/trace + +```json +{ + "transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "PAYMENT", "amount": -30, "…": "…" }, + "lots": [ + { + "amount": 30, + "chain": [ + { "lot": { "id": "…", "expires_at": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" } } }, + { "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "reference_type": "ORDER", "description": "Belanja #ORD-1", "customer": { "name": "Anita" } } } + ] + } + ] +} +``` + +Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat. Tiap `chain` mundur lewat transfer, exchange, atau refund sampai lot pertama dari `EARN`, `ADJUSTMENT`, atau `MIGRATION`. + +### PIN customer + +`DELETE /marketing/customers/:id/pin` dengan `{ "reason": "…" }` memaksa customer membuat PIN baru lewat OTP; admin tidak bisa membuat, mengganti, atau melihat PIN. `security-events` mengembalikan `PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`, `PIN_REMOVED_BY_ADMIN` beserta waktu, IP, dan perangkat. + +## Referensi + +### Tipe mutasi (`type`) + +| `type` | Arah | Arti | `source` / `destination` | +| --- | --- | --- | --- | +| `EARN` | + | Didapat dari order lunas | `ORDER` | +| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` | +| `PAYMENT` | − | Membayar order (EnakPoint saja) | `PAYMENT` | +| `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` | +| `EXCHANGE_OUT` | − | EnakCoin ditukar | `WALLET_TX` (baris `EXCHANGE_IN`) | +| `EXCHANGE_IN` | + | EnakPoint hasil tukar | `WALLET_TX` (baris `EXCHANGE_OUT`) | +| `TRANSFER_OUT` | − | Dikirim ke customer lain | `WALLET_TX` (baris `TRANSFER_IN`) | +| `TRANSFER_IN` | + | Diterima dari customer lain | `WALLET_TX` (baris `TRANSFER_OUT`) | +| `GAME_SPEND` | − | Main game (EnakCoin saja) | `GAME_PLAY` | +| `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` | +| `ADJUSTMENT` | + / − | Koreksi admin | `USER` | +| `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` | + +### Notifikasi push (FCM) + +Semua nilai `data` berupa string. Push hanya sampai ke device yang terdaftar lewat `PUT /customer/devices`. + +| `data.type` | Kapan | Isi `data` lainnya | +| --- | --- | --- | +| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` | +| `WALLET_EXPIRING` | `reminder_days` hari sebelum hangus, sekali per tanggal | `currency`, `amount`, `expiry_date` | +| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` | +| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339, UTC) | + +### Endpoint dan field deprecated + +Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi pindah. + +| Lama | Pengganti | +| --- | --- | +| `GET /customer/points` | `GET /customer/wallet` → `point_balance` | +| `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` | +| `total_points`, `total_tokens`, `points_history`, `tokens_history`, `last_updated` di `/customer/wallet` | `point_balance`, `coin_balance`, `recent_transactions` | +| `token_used`, `tokens_remaining` di response game | `coins_used`, `coins_remaining` | +| `sort_by=token_used` di daftar game play | `sort_by=coins_used` | + +Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md). diff --git a/docs/backoffice-enakpoint.md b/docs/backoffice-enakpoint.md new file mode 100644 index 0000000..599eb34 --- /dev/null +++ b/docs/backoffice-enakpoint.md @@ -0,0 +1,305 @@ +# Backoffice EnakPoint & EnakCoin + +30 Sep 2026 + +Backoffice perlu tujuh layar untuk mengelola program loyalitas: setting per outlet, setting per organisasi (termasuk kedaluwarsa), wallet customer, telusuri mutasi, PIN customer, riwayat setting, dan biaya main game. + +## Layar yang perlu dibuat + +Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin atau Manager, dan otomatis dibatasi ke organisasi user tersebut. Data customer atau outlet organisasi lain dijawab `404`. + +| Layar | Endpoint | Tempat di menu | +| --- | --- | --- | +| Setting loyalitas outlet | `GET` / `PUT /outlets/:outlet_id/loyalty-settings` | Outlet → detail outlet → tab Loyalitas | +| Setting loyalitas organisasi | `GET` / `PUT /marketing/loyalty-settings` (+ `?dry_run=true`) | Marketing → Loyalitas → Pengaturan | +| Riwayat perubahan setting | `GET /marketing/loyalty-settings/history` | Marketing → Loyalitas → Riwayat | +| Wallet customer | `GET /marketing/customers/:id/wallet`, `POST …/wallet/adjust` | Customer → detail customer → tab Wallet | +| Telusuri mutasi | `GET /marketing/wallet-transactions/:id/trace` | Dibuka dari baris riwayat wallet | +| PIN & keamanan customer | `DELETE /marketing/customers/:id/pin`, `GET …/security-events` | Customer → detail customer → tab Keamanan | +| Biaya main game | `PUT` game yang sudah ada, `metadata.coin_cost` | Marketing → Game → edit game | + +Penempatan menu di atas adalah usulan; sesuaikan dengan struktur backoffice yang ada. + +**Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang bisa membayar order; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke EnakPoint. Nilai rupiah EnakPoint selalu ditulis "setara potongan Rp …", tidak pernah "saldo Rp …", karena saldo tidak bisa dicairkan. + +**Format response.** Sukses `{ "success": true, "data": … }`; gagal `{ "success": false, "errors": [{ "code", "entity", "cause" }] }`. Tampilkan `cause` sebagai pesan (lihat bagian Pesan error). + +## Setting loyalitas outlet + +Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari order, dan apakah outlet menerima pembayaran EnakPoint. Semua nilai default mati sampai owner menyalakannya. + +`GET /outlets/:outlet_id/loyalty-settings` → isi form. `PUT` ke path yang sama dengan objek yang sama untuk menyimpan; field yang tidak dikirim tetap, field tak dikenal ditolak. + +```json +{ + "point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null }, + "coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null }, + "point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 } +} +``` + +| Field | Label usulan | Tipe | Default | Validasi | +| --- | --- | --- | --- | --- | +| `point.enabled` / `coin.enabled` | Beri EnakPoint / EnakCoin | toggle | mati | – | +| `earn_per_amount` | Setiap belanja Rp … | Rp | 100 (point), 25.000 (coin) | > 0 | +| `earn_value` | … mendapat | angka | 1 | ≥ 0 | +| `min_order_amount` | Minimal belanja | Rp | 0 | ≥ 0 | +| `max_per_order` | Maksimal per order | angka, boleh kosong | kosong = tanpa batas | ≥ 0 | +| `point_payment.accept_payment` | Terima pembayaran EnakPoint | toggle | mati | – | +| `min_payment_points` | Minimal EnakPoint per pembayaran | angka | 1 | ≥ 1 | +| `max_payment_percent` | Maksimal porsi order dibayar EnakPoint | % | 100 | 0–100 | + +**Cashback efektif.** Response membawa `point_cashback_percent` dan `point_value`. Tampilkan persentase di samping field earning EnakPoint, mis. "setara cashback 1%", dan hitung ulang di sisi klien saat owner mengetik: `earn_value × point_value ÷ earn_per_amount × 100`. Tujuannya agar owner tidak salah membaca skala (1 per Rp 100 bukan 1 per Rp 1). + +**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak, dan bagian yang dibayar EnakPoint tidak ikut dihitung. + +Setelah `PUT`, response membawa `changes` (key yang berubah); tampilkan toast singkat, mis. "2 pengaturan disimpan". Mematikan `accept_payment` langsung menyembunyikan method EnakPoint di kasir outlet itu. + +## Setting loyalitas organisasi + +Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan kedaluwarsa berlaku sama untuk semua outlet, jadi diatur sekali per organisasi. Mengubah nilai EnakPoint atau kurs langsung mengubah daya beli semua saldo customer, jadi layar ini wajib menampilkan dampaknya sebelum disimpan. + +```json +{ + "point_value": 1, + "exchange": { "coin_amount": 1, "point_amount": 1 }, + "transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null }, + "point_expiry": { "…": "lihat bagian kedaluwarsa" }, + "coin_expiry": { "…": "lihat bagian kedaluwarsa" } +} +``` + +| Field | Label usulan | Default | Validasi | +| --- | --- | --- | --- | +| `point_value` | Nilai 1 EnakPoint (Rp) | 1 | ≥ 1 | +| `exchange.coin_amount` : `exchange.point_amount` | Kurs tukar: … EnakCoin = … EnakPoint | 1 : 1 | keduanya ≥ 1 | +| `transfer.enabled` | Izinkan transfer antar customer | aktif | – | +| `transfer.min_amount` | Minimal per transfer | 1 | ≥ 1 | +| `transfer.max_per_transaction` | Maksimal per transfer | kosong = tanpa batas | ≥ 1 | +| `transfer.daily_limit` | Batas harian per customer | kosong = tanpa batas | ≥ 1, dihitung per currency, reset tengah malam WIB | + +### Alur simpan + +1. Owner mengubah form. +2. Tombol Simpan memanggil `PUT /marketing/loyalty-settings?dry_run=true` dengan objek yang diubah. Tidak ada yang tersimpan. +3. Bila `changes` kosong, beri tahu "tidak ada perubahan" dan berhenti. +4. Tampilkan dialog konfirmasi berisi `changes`, `impact` (bila `point_value` atau kurs berubah), dan `expiry_activations` (bila ada, lihat bagian kedaluwarsa). +5. Konfirmasi memanggil `PUT` yang sama tanpa `dry_run`. + +### Dialog dampak + +`impact` berisi saldo beredar organisasi dan nilainya sebelum/sesudah: + +| Field `impact` | Tampilkan sebagai | +| --- | --- | +| `outstanding_points` | EnakPoint beredar | +| `point_rupiah_before` → `point_rupiah_after` | Setara potongan Rp … → Rp … | +| `outstanding_coins` | EnakCoin beredar | +| `coins_as_points_before` → `coins_as_points_after` | Bila semua ditukar: … EnakPoint → … EnakPoint | +| `coin_rupiah_before` → `coin_rupiah_after` | Setara potongan Rp … → Rp … | + +Contoh kalimat: "Menaikkan nilai EnakPoint dari Rp 1 ke Rp 2 membuat 1.250.000 EnakPoint yang beredar setara potongan Rp 2.500.000 (sebelumnya Rp 1.250.000)." Perubahan hanya berlaku ke depan: pembayaran, refund, dan exchange yang sudah terjadi memakai nilai saat itu. + +## Pengaturan kedaluwarsa + +Kedaluwarsa diatur terpisah untuk EnakPoint (`point_expiry`) dan EnakCoin (`coin_expiry`) dengan salah satu dari dua model; defaultnya mati, dan bila dinyalakan defaultnya hangus setiap 31 Desember. + +```json +"point_expiry": { + "enabled": true, + "mode": "FIXED_DATE", + "fixed_dates": ["12-31"], + "grace_months": 3, + "period": 12, + "unit": "MONTH", + "end_of_month": false, + "reminder_days": 7 +} +``` + +| Field | Tampil saat | Label usulan | Validasi | +| --- | --- | --- | --- | +| `enabled` | selalu | Saldo bisa kedaluwarsa | – | +| `mode` | aktif | Model: Tanggal tetap / Sejak didapat | `FIXED_DATE` atau `ROLLING` | +| `fixed_dates` | `FIXED_DATE` | Tanggal hangus setiap tahun | minimal satu, format `MM-DD`, `02-29` ditolak | +| `grace_months` | `FIXED_DATE` | Periode tanggung (bulan) | 0–24, default 3 | +| `period` + `unit` | `ROLLING` | Berlaku selama … hari/bulan | period ≥ 1, `DAY` atau `MONTH` | +| `end_of_month` | `ROLLING` | Bulatkan ke akhir bulan | – | +| `reminder_days` | aktif | Ingatkan customer … hari sebelumnya | ≥ 0, 0 = tanpa pengingat | + +**Tanggal tetap (`FIXED_DATE`).** Semua saldo hangus di tanggal yang sama, mis. 31 Desember, atau 30 Juni dan 31 Desember untuk dua kali setahun. Saldo yang didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya, jadi saldo yang didapat 1 Oktober dengan tanggung 3 bulan hangus 31 Desember tahun depan. Untuk input `fixed_dates`, pakai pemilih tanggal+bulan tanpa tahun. + +**Sejak didapat (`ROLLING`).** Tiap saldo berlaku `period` hari atau bulan sejak masuk, mis. 12 bulan. Dengan `end_of_month`, saldo yang didapat 14 Maret 2026 hangus 31 Maret 2027. + +**Preview.** Response `GET`, `PUT`, dan dry run membawa `expiry_preview.point` dan `.coin`: kapan saldo yang didapat sekarang akan kedaluwarsa (`null` = tidak). Tampilkan di bawah form: "EnakPoint yang didapat hari ini kedaluwarsa pada 31 Des 2026." Karena dihitung dari nilai yang dikirim, dry run bisa dipakai untuk memperbarui preview saat owner mengubah pilihan. + +**Menyalakan pertama kali.** Saldo lama yang belum punya tanggal ikut diberi tanggal, dengan masa berlaku penuh: tanggal hangus kedua berikutnya (`FIXED_DATE`) atau satu periode sejak hari ini (`ROLLING`). Dry run mengembalikan `expiry_activations`; tampilkan di dialog konfirmasi dengan kalimat tegas, mis. "1.250.000 EnakPoint milik customer yang ada sekarang akan kedaluwarsa pada 31 Des 2027. Tindakan ini tidak bisa dibatalkan dengan mematikan kedaluwarsa." + +| Field `expiry_activations[]` | Arti | +| --- | --- | +| `currency` | `POINT` atau `COIN` | +| `lots` | Jumlah paket saldo yang diberi tanggal | +| `amount` | Total saldo yang diberi tanggal | +| `expires_at` | Tanggal kedaluwarsanya | + +**Aturan lain yang perlu dijelaskan di layar:** + +- Mengubah model atau masa berlaku hanya berlaku untuk saldo yang masuk setelahnya. +- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal. +- Saldo yang ditransfer atau ditukar membawa tanggal kedaluwarsa aslinya. +- Saldo hangus tanpa kompensasi apa pun. Customer mendapat pengingat push `reminder_days` hari sebelumnya dan notifikasi saat hangus. + +## Wallet customer + +Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo dan asal-usulnya, mengoreksi saldo, dan menelusuri satu mutasi sampai ke order asalnya. + +### Saldo, lot, dan riwayat + +`GET /marketing/customers/:id/wallet?page=1&limit=20¤cy=POINT&type=PAYMENT,EARN&from=2026-09-01&to=2026-09-30` (semua query opsional, sama seperti riwayat di aplikasi customer) + +```json +{ + "customer": { "id": "…", "name": "Budi Santoso", "phone": "081234561234" }, + "point_balance": 12650, + "coin_balance": 8, + "spendable_point_balance": 12500, + "spendable_coin_balance": 8, + "lots": [ + { "id": "…", "currency": "POINT", "original_amount": 875, "remaining_amount": 875, "expires_at": "2026-12-31T23:59:59+07:00", "expired": false, "source_transaction_id": "…", "origin_lot_id": null, "created_at": "…" } + ], + "transactions": { + "data": [ + { + "id": "…", "currency": "POINT", "type": "TRANSFER_OUT", "amount": -120, "balance_after": 12650, + "description": "Transfer ke An*** (08**-****-5678)", + "destination": { "type": "WALLET_TX", "id": "…" }, + "counterparty": { "id": "…", "name": "Anita Rahma" }, + "created_by": null, "outlet": null, "reason": null, "metadata": {}, + "created_at": "…" + } + ], + "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } + } +} +``` + +- **Saldo:** tampilkan `spendable_*` sebagai saldo utama. `point_balance` / `coin_balance` bisa sedikit lebih besar selama ada lot yang sudah lewat tanggal tapi belum diproses job kedaluwarsa (paling lama sekitar 15 menit). +- **Lot:** tabel paket saldo yang masih berisi, urut dari yang paling cepat kedaluwarsa. Beri tanda untuk `expired: true`. +- **Riwayat:** sama dengan riwayat customer, ditambah nama asli yang disamarkan untuk customer: `counterparty` (lawan transfer), `created_by` (admin pelaku adjustment atau kasir penerima pembayaran), `outlet`, `reason`, dan `metadata` (kurs, nilai EnakPoint yang dibekukan, shortfall). + +### Adjustment manual + +`POST /marketing/customers/:id/wallet/adjust` + +```json +{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-7f3c" } +``` + +| Field | Aturan | +| --- | --- | +| `currency` | `POINT` atau `COIN` | +| `amount` | Bertanda, tidak boleh 0. Positif menambah, negatif mengurangi | +| `reason` | Wajib; tampil di riwayat customer sebagai "Koreksi oleh admin: …" | +| `idempotency_key` | Opsional tapi disarankan: buat satu nilai saat dialog dibuka, supaya klik ganda tidak mengoreksi dua kali | + +Pengurangan yang melebihi saldo yang bisa dipakai ditolak `304`. Adjustment tambah mengikuti aturan kedaluwarsa organisasi. Response: `{ "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }`. Beri catatan di dialog bahwa adjustment tidak disertai pembayaran uang, sehingga alasan tidak boleh "pencairan". + +### Telusuri mutasi + +Dari baris riwayat mana pun, tombol Telusuri memanggil `GET /marketing/wallet-transactions/:id/trace`. + +```json +{ + "transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "currency": "POINT", "type": "PAYMENT", "amount": -30, "description": "Bayar #ORD-0456 di Outlet Kemang (Rp 30)", "reference_type": "PAYMENT", "reference_id": "…", "created_at": "…" }, + "lots": [ + { + "amount": 30, + "chain": [ + { "lot": { "id": "…", "expires_at": "…", "origin_lot_id": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" }, "description": "Transfer dari An*** (08**-****-5678)" } }, + { "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "customer": { "name": "Anita Rahma" }, "reference_type": "ORDER", "reference_id": "…", "description": "Belanja #ORD-1 di Outlet Kemang" } } + ] + } + ] +} +``` + +Tampilkan tiap `lots[]` sebagai rantai dari atas ke bawah: jumlah yang lewat lot itu, lalu setiap langkah `chain` dengan pemilik, tipe, dan deskripsinya. Langkah terakhir selalu `EARN`, `ADJUSTMENT`, atau `MIGRATION`; bila `reference_type` = `ORDER`, jadikan tautan ke detail order. Mutasi keluar menampilkan lot yang dipakai; mutasi masuk menampilkan lot yang dibuatnya. + +## PIN, riwayat setting, game, dan method EnakPoint + +### PIN & keamanan customer + +Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aksi adalah menghapusnya, misalnya bila customer kehilangan akses, sehingga customer harus membuat PIN baru lewat OTP di aplikasi. + +- `DELETE /marketing/customers/:id/pin` dengan body `{ "reason": "Customer ganti nomor HP" }`. `reason` wajib. Tampilkan dialog konfirmasi dengan input alasan. +- `GET /marketing/customers/:id/security-events?page=1&limit=20` untuk tab Keamanan: + +```json +{ + "data": [ + { "id": "…", "event": "PIN_LOCKED", "actor_user": null, "reason": null, "ip_address": "103.10.0.7", "user_agent": "EnakApp/2.4 (Android 14)", "created_at": "…" } + ], + "pagination": { "page": 1, "limit": 20, "total_count": 5, "total_pages": 1 } +} +``` + +| `event` | Label usulan | +| --- | --- | +| `PIN_SET` | PIN dibuat | +| `PIN_CHANGED` | PIN diganti | +| `PIN_RESET` | PIN direset lewat OTP (transfer ditahan 24 jam) | +| `PIN_FAILED` | PIN salah dimasukkan | +| `PIN_LOCKED` | PIN terkunci 30 menit | +| `PIN_REMOVED_BY_ADMIN` | PIN dihapus admin (`actor_user`, `reason` terisi) | + +### Riwayat perubahan setting + +`GET /marketing/loyalty-settings/history?page=1&limit=20` untuk setting organisasi; tambah `&outlet_id=…` untuk riwayat satu outlet. + +```json +{ "id": "…", "organization_id": "…", "outlet_id": null, "key": "loyalty.point.value", "old_value": "1", "new_value": "2", "changed_by": "…", "created_at": "…" } +``` + +`old_value` `null` berarti sebelumnya masih nilai default. Tampilkan `key` dengan label yang sama seperti di form (mis. `loyalty.point.value` → "Nilai 1 EnakPoint"), dan `changed_by` sebagai nama user. + +### Biaya main game + +Semua game (spin, raffle, minigame) memakai EnakCoin yang sama. Biaya per main diisi di `metadata.coin_cost` saat membuat atau mengedit game (`/marketing/games`): bilangan bulat ≥ 1, default 1 bila kosong. Nilai pecahan, 0, atau teks membuat game tidak bisa dimainkan. Karena `metadata` dikirim utuh, pertahankan key metadata lain saat menyimpan. Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint. + +### Method pembayaran EnakPoint + +Method "EnakPoint" (tipe `point`) dibuat otomatis untuk setiap organisasi. Di layar Payment Method (`/payment-methods`): + +- Tampilkan sebagai method sistem: tombol hapus dan pilihan ubah tipe disembunyikan; backend menolaknya (`304`). Nama boleh diganti. +- Tipe `point` tidak ditawarkan saat membuat method baru. +- Kasir hanya melihatnya di outlet yang menyalakan "Terima pembayaran EnakPoint". + +Di laporan per payment method, EnakPoint tampil terpisah dan **tidak** dihitung sebagai kas masuk. + +## Pesan error dan checklist + +| `code` | HTTP | Kapan terjadi di backoffice | Yang ditampilkan | +| --- | --- | --- | --- | +| `303`, `310` | 400 | Body tidak valid, field tak dikenal di `PUT` setting, UUID salah | Pesan umum "Data tidak valid" + `cause` untuk developer | +| `304` | 400 | Nilai di luar batas, adjustment melebihi saldo, alasan kosong, hapus/ubah method EnakPoint | `cause` di dekat field atau di toast | +| `404` | 404 | Customer, outlet, atau mutasi bukan milik organisasi ini | "Data tidak ditemukan" | +| `900` | 500 | Kesalahan server | "Terjadi kesalahan, coba lagi" | + +Pesan `cause` saat ini berbahasa Inggris, mis. `invalid loyalty settings: loyalty.point.earn_per_amount must be at least 1`. Untuk validasi form, lebih baik cek batasnya di sisi klien (tabel di tiap bagian) dan tampilkan `cause` hanya sebagai cadangan. + +### Checklist rilis + +- [ ] Form setting outlet menampilkan cashback efektif dan contoh earning. +- [ ] Setting organisasi selalu lewat dry run dan dialog konfirmasi sebelum disimpan. +- [ ] Dialog konfirmasi menampilkan `impact` saat nilai EnakPoint atau kurs berubah. +- [ ] Dialog konfirmasi menampilkan `expiry_activations` saat kedaluwarsa dinyalakan pertama kali. +- [ ] Preview "yang didapat hari ini kedaluwarsa pada …" tampil di bawah pengaturan kedaluwarsa. +- [ ] Wallet customer menampilkan saldo yang bisa dipakai, lot, dan riwayat dengan nama asli. +- [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`. +- [ ] Tombol Telusuri ada di setiap baris riwayat. +- [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log. +- [ ] Method EnakPoint tampil sebagai method sistem. +- [ ] Form game punya input `coin_cost`. +- [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …". + +Pembayaran EnakPoint belum boleh dirilis ke outlet sebelum tinjauan keuangan (N2) dan legal (N3) selesai, dan transfer menunggu tinjauan legal (N3). Layar backoffice boleh disiapkan lebih dulu. -- 2.54.0 From 4e24f9bbb0e6313ddc6a00a95b53c0071cba877d Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 15:16:15 +0700 Subject: [PATCH 47/47] Revert "feat(loyalty): EnakPoint & EnakCoin" (#32) This reverts merge commit 645da30, returning main to f0ff59f. Co-Authored-By: Claude Opus 5.5 --- Makefile | 8 - cmd/wallet-migrate/main.go | 73 -- docs/api-enakpoint.md | 439 ---------- docs/backoffice-enakpoint.md | 305 ------- docs/integration-enakpoint.md | 635 -------------- go.mod | 2 - go.sum | 4 - internal/app/app.go | 150 +--- internal/constants/error.go | 26 +- internal/constants/loyalty.go | 86 -- internal/constants/payment.go | 3 - internal/constants/wallet.go | 44 - internal/contract/analytics_contract.go | 6 - internal/contract/campaign_contract.go | 10 +- internal/contract/customer_device_contract.go | 11 - internal/contract/customer_pin_contract.go | 39 - internal/contract/customer_points_contract.go | 58 ++ internal/contract/customer_tokens_contract.go | 52 ++ internal/contract/game_play_contract.go | 25 +- internal/contract/order_contract.go | 44 +- internal/contract/payment_method_contract.go | 14 +- internal/contract/spin_game_contract.go | 8 +- internal/contract/wallet_contract.go | 31 - internal/entities/analytics.go | 20 +- internal/entities/campaign.go | 25 +- internal/entities/campaign_test.go | 18 - internal/entities/entities.go | 6 - internal/entities/game_play.go | 2 +- internal/entities/loyalty_setting_change.go | 34 - internal/entities/payment.go | 21 +- internal/entities/wallet.go | 113 --- internal/handler/customer_device_handler.go | 45 - .../handler/customer_order_payment_handler.go | 35 - internal/handler/customer_pin_handler.go | 151 ---- internal/handler/customer_points_handler.go | 82 +- internal/handler/customer_wallet_db_test.go | 218 ----- internal/handler/customer_wallet_handler.go | 92 -- internal/handler/gamification_handler.go | 471 +++++++++++ internal/handler/loyalty_settings_db_test.go | 169 ---- internal/handler/loyalty_settings_handler.go | 117 --- .../handler/loyalty_settings_org_db_test.go | 180 ---- internal/handler/order_handler.go | 13 - internal/handler/payment_method_handler.go | 10 - internal/handler/point_payment_handler.go | 28 - internal/handler/spin_game_handler.go | 11 +- internal/handler/wallet_admin_db_test.go | 203 ----- internal/handler/wallet_admin_handler.go | 99 --- internal/mappers/campaign_mapper.go | 8 +- internal/mappers/customer_points_mapper.go | 46 + internal/mappers/customer_tokens_mapper.go | 48 ++ internal/mappers/game_play_mapper.go | 4 +- internal/mappers/order_mapper.go | 2 - internal/mappers/payment_method_mapper.go | 1 - internal/mappers/spin_game_mapper.go | 6 +- internal/middleware/auth_middleware.go | 8 - internal/models/analytics.go | 27 +- internal/models/campaign.go | 10 +- internal/models/customer_pin.go | 47 -- internal/models/customer_points.go | 71 +- internal/models/customer_tokens.go | 44 + internal/models/game_play.go | 14 +- internal/models/loyalty.go | 193 ----- internal/models/loyalty_test.go | 32 - internal/models/order.go | 3 - internal/models/payment.go | 9 +- internal/models/payment_method.go | 12 +- internal/models/spin_game.go | 6 +- internal/models/wallet.go | 166 ---- internal/models/wallet_move.go | 73 -- internal/models/wallet_trace.go | 46 - internal/processor/analytics_processor.go | 29 +- .../processor/analytics_processor_test.go | 38 +- internal/processor/campaign_processor.go | 8 +- .../processor/customer_device_processor.go | 77 -- .../customer_device_processor_test.go | 136 --- internal/processor/customer_pin_processor.go | 471 ----------- .../customer_pin_processor_db_test.go | 257 ------ .../processor/customer_pin_processor_test.go | 107 --- .../processor/customer_points_processor.go | 219 +++-- .../processor/customer_tokens_processor.go | 198 +++++ internal/processor/earning_calculator.go | 85 -- internal/processor/earning_calculator_test.go | 121 --- internal/processor/earning_processor.go | 217 ----- .../processor/earning_processor_db_test.go | 233 ------ internal/processor/earning_reversal.go | 167 ---- .../processor/earning_reversal_db_test.go | 206 ----- internal/processor/earning_reversal_test.go | 25 - internal/processor/game_play_processor.go | 248 ++---- .../processor/game_play_processor_db_test.go | 69 -- .../processor/game_play_processor_test.go | 245 ------ internal/processor/loyalty_expiry.go | 170 ---- internal/processor/loyalty_expiry_test.go | 115 --- .../processor/loyalty_settings_processor.go | 530 ------------ .../loyalty_settings_processor_test.go | 385 --------- internal/processor/order_paid_hook_test.go | 272 ------ internal/processor/order_processor.go | 255 +----- internal/processor/otp_processor.go | 4 - internal/processor/payment_code_processor.go | 101 --- .../processor/payment_code_processor_test.go | 136 --- .../processor/payment_method_processor.go | 39 +- internal/processor/point_payment_db_test.go | 401 --------- .../processor/point_payment_method_db_test.go | 118 --- internal/processor/point_payment_processor.go | 336 -------- .../processor/point_payment_processor_test.go | 39 - internal/processor/point_payment_refund.go | 157 ---- internal/processor/point_refund_db_test.go | 151 ---- internal/processor/wallet_admin_processor.go | 223 ----- .../processor/wallet_exchange_processor.go | 289 ------- .../wallet_exchange_processor_test.go | 354 -------- internal/processor/wallet_expiry_processor.go | 196 ----- .../processor/wallet_expiry_processor_test.go | 244 ------ .../processor/wallet_migration_processor.go | 184 ---- .../wallet_migration_processor_db_test.go | 167 ---- internal/processor/wallet_move_db_test.go | 237 ------ internal/processor/wallet_processor.go | 583 ------------- .../processor/wallet_processor_db_test.go | 142 ---- internal/processor/wallet_processor_test.go | 788 ------------------ internal/processor/wallet_query_processor.go | 336 -------- .../processor/wallet_query_processor_test.go | 266 ------ internal/processor/wallet_trace_processor.go | 180 ---- .../processor/wallet_trace_processor_test.go | 161 ---- .../processor/wallet_transfer_processor.go | 307 ------- .../wallet_transfer_processor_test.go | 243 ------ internal/repository/analytics_repository.go | 19 +- .../repository/customer_device_repository.go | 84 -- .../repository/customer_pin_repository.go | 220 ----- .../repository/customer_points_repository.go | 97 +++ .../repository/customer_tokens_repository.go | 131 +++ internal/repository/earning_repository.go | 275 ------ internal/repository/game_play_repository.go | 14 +- internal/repository/game_prize_repository.go | 31 +- internal/repository/game_repository.go | 16 +- .../repository/loyalty_settings_repository.go | 219 ----- .../loyalty_settings_repository_test.go | 149 ---- .../repository/payment_code_repository.go | 103 --- .../repository/payment_method_repository.go | 2 - .../repository/point_payment_repository.go | 339 -------- internal/repository/tx_manager.go | 7 - .../repository/wallet_admin_repository.go | 126 --- .../repository/wallet_expiry_repository.go | 118 --- .../repository/wallet_migration_repository.go | 134 --- internal/repository/wallet_move_repository.go | 82 -- .../repository/wallet_query_repository.go | 202 ----- .../wallet_reconciliation_repository.go | 160 ---- .../wallet_reconciliation_repository_test.go | 141 ---- internal/repository/wallet_repository.go | 295 ------- internal/repository/wallet_repository_test.go | 353 -------- .../repository/wallet_trace_repository.go | 109 --- internal/router/router.go | 79 +- internal/router/router_test.go | 59 -- internal/service/campaign_service.go | 14 +- internal/service/customer_device_service.go | 58 -- .../service/customer_order_payment_service.go | 34 - internal/service/customer_pin_service.go | 140 ---- internal/service/customer_pin_service_test.go | 50 -- internal/service/customer_points_service.go | 16 - internal/service/customer_wallet_service.go | 91 -- internal/service/earning_backfill_job.go | 74 -- internal/service/earning_backfill_job_test.go | 46 - internal/service/gamification_service.go | 174 +++- internal/service/loyalty_settings_service.go | 224 ----- internal/service/order_service.go | 3 +- internal/service/order_service_table_test.go | 8 - internal/service/payment_method_service.go | 17 +- internal/service/point_payment_service.go | 55 -- internal/service/spin_game_service.go | 36 +- internal/service/wallet_admin_service.go | 82 -- internal/service/wallet_expiry_job.go | 77 -- internal/service/wallet_reconciliation_job.go | 212 ----- .../service/wallet_reconciliation_job_test.go | 110 --- internal/transformer/analytics_transformer.go | 7 - .../transformer/gamification_transformer.go | 134 ++- internal/transformer/order_transformer.go | 6 - .../transformer/order_transformer_test.go | 14 - internal/validator/campaign_validator.go | 16 +- internal/validator/gamification_validator.go | 168 +++- .../validator/payment_method_validator.go | 1 - internal/validator/wallet_validator.go | 66 -- internal/validator/wallet_validator_test.go | 43 - .../000090_create_wallet_tables.down.sql | 4 - migrations/000090_create_wallet_tables.up.sql | 131 --- ...0091_create_organization_settings.down.sql | 2 - ...000091_create_organization_settings.up.sql | 35 - ...2_rename_campaign_tokens_to_coins.down.sql | 5 - ...092_rename_campaign_tokens_to_coins.up.sql | 7 - migrations/000093_add_customer_pin.down.sql | 9 - migrations/000093_add_customer_pin.up.sql | 32 - .../000094_add_point_payment_method.down.sql | 16 - .../000094_add_point_payment_method.up.sql | 45 - ...me_plays_token_used_to_coins_used.down.sql | 2 - ...game_plays_token_used_to_coins_used.up.sql | 4 - .../000096_create_customer_devices.down.sql | 1 - .../000096_create_customer_devices.up.sql | 17 - ...97_create_wallet_expiry_reminders.down.sql | 1 - ...0097_create_wallet_expiry_reminders.up.sql | 11 - 195 files changed, 2179 insertions(+), 20060 deletions(-) delete mode 100644 cmd/wallet-migrate/main.go delete mode 100644 docs/api-enakpoint.md delete mode 100644 docs/backoffice-enakpoint.md delete mode 100644 docs/integration-enakpoint.md delete mode 100644 internal/constants/loyalty.go delete mode 100644 internal/constants/wallet.go delete mode 100644 internal/contract/customer_device_contract.go delete mode 100644 internal/contract/customer_pin_contract.go create mode 100644 internal/contract/customer_tokens_contract.go delete mode 100644 internal/contract/wallet_contract.go delete mode 100644 internal/entities/campaign_test.go delete mode 100644 internal/entities/loyalty_setting_change.go delete mode 100644 internal/entities/wallet.go delete mode 100644 internal/handler/customer_device_handler.go delete mode 100644 internal/handler/customer_order_payment_handler.go delete mode 100644 internal/handler/customer_pin_handler.go delete mode 100644 internal/handler/customer_wallet_db_test.go delete mode 100644 internal/handler/customer_wallet_handler.go delete mode 100644 internal/handler/loyalty_settings_db_test.go delete mode 100644 internal/handler/loyalty_settings_handler.go delete mode 100644 internal/handler/loyalty_settings_org_db_test.go delete mode 100644 internal/handler/point_payment_handler.go delete mode 100644 internal/handler/wallet_admin_db_test.go delete mode 100644 internal/handler/wallet_admin_handler.go create mode 100644 internal/mappers/customer_points_mapper.go create mode 100644 internal/mappers/customer_tokens_mapper.go delete mode 100644 internal/models/customer_pin.go create mode 100644 internal/models/customer_tokens.go delete mode 100644 internal/models/loyalty.go delete mode 100644 internal/models/loyalty_test.go delete mode 100644 internal/models/wallet.go delete mode 100644 internal/models/wallet_move.go delete mode 100644 internal/models/wallet_trace.go delete mode 100644 internal/processor/customer_device_processor.go delete mode 100644 internal/processor/customer_device_processor_test.go delete mode 100644 internal/processor/customer_pin_processor.go delete mode 100644 internal/processor/customer_pin_processor_db_test.go delete mode 100644 internal/processor/customer_pin_processor_test.go create mode 100644 internal/processor/customer_tokens_processor.go delete mode 100644 internal/processor/earning_calculator.go delete mode 100644 internal/processor/earning_calculator_test.go delete mode 100644 internal/processor/earning_processor.go delete mode 100644 internal/processor/earning_processor_db_test.go delete mode 100644 internal/processor/earning_reversal.go delete mode 100644 internal/processor/earning_reversal_db_test.go delete mode 100644 internal/processor/earning_reversal_test.go delete mode 100644 internal/processor/game_play_processor_db_test.go delete mode 100644 internal/processor/game_play_processor_test.go delete mode 100644 internal/processor/loyalty_expiry.go delete mode 100644 internal/processor/loyalty_expiry_test.go delete mode 100644 internal/processor/loyalty_settings_processor.go delete mode 100644 internal/processor/loyalty_settings_processor_test.go delete mode 100644 internal/processor/order_paid_hook_test.go delete mode 100644 internal/processor/payment_code_processor.go delete mode 100644 internal/processor/payment_code_processor_test.go delete mode 100644 internal/processor/point_payment_db_test.go delete mode 100644 internal/processor/point_payment_method_db_test.go delete mode 100644 internal/processor/point_payment_processor.go delete mode 100644 internal/processor/point_payment_processor_test.go delete mode 100644 internal/processor/point_payment_refund.go delete mode 100644 internal/processor/point_refund_db_test.go delete mode 100644 internal/processor/wallet_admin_processor.go delete mode 100644 internal/processor/wallet_exchange_processor.go delete mode 100644 internal/processor/wallet_exchange_processor_test.go delete mode 100644 internal/processor/wallet_expiry_processor.go delete mode 100644 internal/processor/wallet_expiry_processor_test.go delete mode 100644 internal/processor/wallet_migration_processor.go delete mode 100644 internal/processor/wallet_migration_processor_db_test.go delete mode 100644 internal/processor/wallet_move_db_test.go delete mode 100644 internal/processor/wallet_processor.go delete mode 100644 internal/processor/wallet_processor_db_test.go delete mode 100644 internal/processor/wallet_processor_test.go delete mode 100644 internal/processor/wallet_query_processor.go delete mode 100644 internal/processor/wallet_query_processor_test.go delete mode 100644 internal/processor/wallet_trace_processor.go delete mode 100644 internal/processor/wallet_trace_processor_test.go delete mode 100644 internal/processor/wallet_transfer_processor.go delete mode 100644 internal/processor/wallet_transfer_processor_test.go delete mode 100644 internal/repository/customer_device_repository.go delete mode 100644 internal/repository/customer_pin_repository.go create mode 100644 internal/repository/customer_points_repository.go create mode 100644 internal/repository/customer_tokens_repository.go delete mode 100644 internal/repository/earning_repository.go delete mode 100644 internal/repository/loyalty_settings_repository.go delete mode 100644 internal/repository/loyalty_settings_repository_test.go delete mode 100644 internal/repository/payment_code_repository.go delete mode 100644 internal/repository/point_payment_repository.go delete mode 100644 internal/repository/wallet_admin_repository.go delete mode 100644 internal/repository/wallet_expiry_repository.go delete mode 100644 internal/repository/wallet_migration_repository.go delete mode 100644 internal/repository/wallet_move_repository.go delete mode 100644 internal/repository/wallet_query_repository.go delete mode 100644 internal/repository/wallet_reconciliation_repository.go delete mode 100644 internal/repository/wallet_reconciliation_repository_test.go delete mode 100644 internal/repository/wallet_repository.go delete mode 100644 internal/repository/wallet_repository_test.go delete mode 100644 internal/repository/wallet_trace_repository.go delete mode 100644 internal/router/router_test.go delete mode 100644 internal/service/customer_device_service.go delete mode 100644 internal/service/customer_order_payment_service.go delete mode 100644 internal/service/customer_pin_service.go delete mode 100644 internal/service/customer_pin_service_test.go delete mode 100644 internal/service/customer_wallet_service.go delete mode 100644 internal/service/earning_backfill_job.go delete mode 100644 internal/service/earning_backfill_job_test.go delete mode 100644 internal/service/loyalty_settings_service.go delete mode 100644 internal/service/point_payment_service.go delete mode 100644 internal/service/wallet_admin_service.go delete mode 100644 internal/service/wallet_expiry_job.go delete mode 100644 internal/service/wallet_reconciliation_job.go delete mode 100644 internal/service/wallet_reconciliation_job_test.go delete mode 100644 internal/validator/wallet_validator.go delete mode 100644 internal/validator/wallet_validator_test.go delete mode 100644 migrations/000090_create_wallet_tables.down.sql delete mode 100644 migrations/000090_create_wallet_tables.up.sql delete mode 100644 migrations/000091_create_organization_settings.down.sql delete mode 100644 migrations/000091_create_organization_settings.up.sql delete mode 100644 migrations/000092_rename_campaign_tokens_to_coins.down.sql delete mode 100644 migrations/000092_rename_campaign_tokens_to_coins.up.sql delete mode 100644 migrations/000093_add_customer_pin.down.sql delete mode 100644 migrations/000093_add_customer_pin.up.sql delete mode 100644 migrations/000094_add_point_payment_method.down.sql delete mode 100644 migrations/000094_add_point_payment_method.up.sql delete mode 100644 migrations/000095_rename_game_plays_token_used_to_coins_used.down.sql delete mode 100644 migrations/000095_rename_game_plays_token_used_to_coins_used.up.sql delete mode 100644 migrations/000096_create_customer_devices.down.sql delete mode 100644 migrations/000096_create_customer_devices.up.sql delete mode 100644 migrations/000097_create_wallet_expiry_reminders.down.sql delete mode 100644 migrations/000097_create_wallet_expiry_reminders.up.sql diff --git a/Makefile b/Makefile index 48f2b97..6b79f01 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,6 @@ help: @echo " migration-up Up migrations" @echo " migration-up ENV=production Up migrations (production DB)" @echo " migration-down Down last migration" - @echo " wallet-migrate args=-dry-run Move legacy points/tokens into the wallet" @echo @echo " docker-up Up docker services" @echo " docker-down Down docker services" @@ -106,13 +105,6 @@ migration-down: migration-force: @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 seeder-create: @migrate create -ext sql -dir ./seeders -seq $(name) diff --git a/cmd/wallet-migrate/main.go b/cmd/wallet-migrate/main.go deleted file mode 100644 index 7cd2652..0000000 --- a/cmd/wallet-migrate/main.go +++ /dev/null @@ -1,73 +0,0 @@ -// 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") -} diff --git a/docs/api-enakpoint.md b/docs/api-enakpoint.md deleted file mode 100644 index 8bd3837..0000000 --- a/docs/api-enakpoint.md +++ /dev/null @@ -1,439 +0,0 @@ -# API EnakPoint & EnakCoin - -30 Sep 2026 - -Semua endpoint EnakPoint (`POINT`, bisa bayar order) dan EnakCoin (`COIN`, untuk game dan ditukar ke EnakPoint) ada di bawah base URL `/api/v1`, memakai satu format response, dan semua jumlah berupa bilangan bulat. - -## Konvensi umum - -| Klien | Autentikasi | Prefix | -| --- | --- | --- | -| Customer app / self-order | `Authorization: Bearer ` | `/api/v1/customer` | -| POS | Token user (kasir/manager) | `/api/v1` | -| Dashboard | Token user, role Admin atau Manager | `/api/v1/marketing`, `/api/v1/outlets` | - -**Format response.** Sukses: `{"success": true, "data": {…}, "errors": null}`. Gagal: `{"success": false, "data": null, "errors": [{"code": "304", "entity": "wallet_service", "cause": "…"}]}`. Tampilkan `cause` sebagai alasan penolakan. - -| `code` | HTTP | Arti | -| --- | --- | --- | -| `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format | -| `304` | 400 | Ditolak aturan bisnis (saldo kurang, di luar batas, dst.) | -| `404` | 404 | Tidak ditemukan, juga untuk data milik customer atau organisasi lain | -| `429` | 429 | OTP diminta ulang terlalu cepat | -| `PIN_NOT_SET` | 403 | Customer belum membuat PIN | -| `PIN_INVALID` | 400 | PIN salah | -| `PIN_LOCKED` | 423 | PIN terkunci 30 menit setelah 5 kali salah | -| `TRANSFER_BLOCKED` | 403 | Transfer ditahan 24 jam setelah reset PIN | -| `900` | 500 | Kesalahan server | - -**Error PIN** membawa `data` yang tidak `null`: `{"code": "PIN_INVALID", "remaining_attempts": 3}`, `{"code": "PIN_LOCKED", "locked_until": "…"}`, atau `{"code": "TRANSFER_BLOCKED", "transfer_blocked_until": "…"}`. Endpoint yang menerima `pin` bisa mengembalikan salah satunya. PIN selalu dikirim sebagai string 6 digit. - -**Idempotency.** Exchange dan transfer wajib header `Idempotency-Key` (maks. 50 karakter, `X-Idempotency-Key` juga diterima): satu key per percobaan, dan key yang sama dipakai ulang saat retry. Retry mengembalikan hasil pertama dengan `replayed: true`. `POST /payments` wajib `X-Idempotency-Key` seperti pembayaran lain. - -**Waktu.** Tanggal kedaluwarsa dan filter tanggal memakai WIB. Saldo berlaku sampai 23:59:59 WIB pada tanggal kedaluwarsanya. - -## Customer app: saldo & riwayat - -| Method | Path | Keterangan | -| --- | --- | --- | -| GET | `/customer/wallet` | Saldo, nilai rupiah, kedaluwarsa terdekat, 5 mutasi terakhir | -| GET | `/customer/wallet/transactions` | Riwayat mutasi, dengan pagination dan filter | -| GET | `/customer/wallet/expiring` | Saldo yang akan kedaluwarsa, per currency dan tanggal | -| PUT | `/customer/devices` | Daftarkan token FCM device | -| DELETE | `/customer/devices/:device_id` | Hapus device saat logout | - -### GET /customer/wallet - -```json -{ - "point_balance": 12500, - "coin_balance": 8, - "point_value": 1, - "point_discount_value": 12500, - "nearest_expiring": { - "point": { "amount": 150, "date": "2026-12-31" }, - "coin": null - }, - "recent_transactions": [ "… sama seperti item riwayat …" ] -} -``` - -- `point_balance` / `coin_balance` = saldo yang bisa dipakai sekarang. -- `point_discount_value` = `point_balance × point_value`; tampilkan sebagai "setara potongan Rp …", bukan saldo uang. -- `nearest_expiring.point` / `.coin` bernilai `null` bila tidak ada yang akan kedaluwarsa. - -### GET /customer/wallet/transactions - -| Query | Tipe | Keterangan | -| --- | --- | --- | -| `page` | int | Default 1 | -| `limit` | int | 1–100, default 20 | -| `currency` | `POINT` \| `COIN` | Opsional | -| `type` | string | Satu tipe atau beberapa dipisah koma, mis. `EARN,PAYMENT` | -| `from`, `to` | `YYYY-MM-DD` | Tanggal WIB, inklusif | - -```json -{ - "data": [ - { - "id": "…", - "currency": "POINT", - "type": "EARN", - "amount": 875, - "balance_after": 12500, - "description": "Belanja #ORD-0123 di Outlet Kemang", - "source": { "type": "ORDER", "id": "…" }, - "outlet_id": "…", - "group_id": null, - "expires_at": "2026-12-31T23:59:59+07:00", - "lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }], - "created_at": "2026-09-30T12:01:00Z" - } - ], - "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } -} -``` - -`amount` bertanda (+ menambah, − mengurangi). Penambahan membawa `source`, pengurangan membawa `destination`, keduanya `{ type, id }`. Dua baris exchange atau transfer berbagi `group_id`. Daftar tipe ada di bagian Referensi. - -### GET /customer/wallet/expiring - -```json -{ - "point": [ - { "amount": 150, "date": "2026-10-31" }, - { "amount": 200, "date": "2026-12-31" } - ], - "coin": [] -} -``` - -Terurut dari tanggal terdekat. Daftar kosong berarti tidak ada yang akan kedaluwarsa. - -### PUT /customer/devices - -```json -{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" } -``` - -Panggil setelah login dan setiap kali FCM memberi token baru. `device_id` dan `fcm_token` wajib; `platform` = `android` | `ios` | `web`. Satu token hanya milik satu customer: customer lain yang mendaftarkan token yang sama mengambil alih HP itu. Response: `{ "device_id": "a1b2c3" }`. - -## Customer app: PIN - -PIN 6 digit wajib untuk bayar, kode bayar, exchange, dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu. - -| Method | Path | Body | Response | -| --- | --- | --- | --- | -| GET | `/customer/pin/status` | – | `{ "has_pin", "locked_until", "transfer_blocked_until" }` | -| POST | `/customer/pin/otp` | `{ "purpose": "pin_setup" }` atau `"pin_reset"` | `{ "purpose", "otp_token", "expires_at" }` | -| POST | `/customer/pin` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN | -| PUT | `/customer/pin` | `{ "old_pin", "pin", "confirm_pin" }` | Status PIN | -| POST | `/customer/pin/reset` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN | - -1. **Buat PIN:** minta OTP dengan `purpose: "pin_setup"` (dikirim lewat WhatsApp), lalu `POST /customer/pin` dengan `otp_token` dari response OTP dan kode yang diterima customer. -2. **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu `POST /customer/pin/reset`. Reset membuka kunci PIN, tapi transfer keluar ditahan 24 jam; pembayaran dan exchange tetap bisa. -3. **Ganti PIN:** `PUT /customer/pin` dengan PIN lama. - -PIN baru ditolak `304` bila bukan 6 digit, konfirmasinya beda, semua digit sama (`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir (`DDMMYY` / `YYMMDD`). OTP yang diminta terlalu cepat dijawab `429`. Penanganan `PIN_INVALID`, `PIN_LOCKED`, dan `TRANSFER_BLOCKED` ada di Konvensi umum. - -## Customer app: bayar, exchange, transfer, game - -| Method | Path | PIN | Idempotency-Key | -| --- | --- | --- | --- | -| POST | `/customer/wallet/payment-code` | Ya | – | -| POST | `/customer/orders/:id/pay-with-points` | Ya | – | -| GET | `/customer/wallet/exchange/preview?coins=` | – | – | -| POST | `/customer/wallet/exchange` | Ya | Wajib | -| GET | `/customer/wallet/transfer/recipient?phone=` | – | – | -| POST | `/customer/wallet/transfer` | Ya | Wajib | -| POST | `/customer/spin` | – | – | - -### POST /customer/wallet/payment-code - -Body `{ "pin": "482913" }`. Response: - -```json -{ "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" } -``` - -Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR untuk kasir. Berlaku 2 menit, sekali pakai, hanya untuk customer ini; kode baru membatalkan kode lama. - -### POST /customer/orders/:id/pay-with-points - -Body `{ "points": 12500, "pin": "482913" }`. Hanya untuk order milik customer yang login (order lain `404`). Response sama dengan pembayaran POS (bagian POS). Batas dan aturan penolakan juga sama. - -### GET /customer/wallet/exchange/preview?coins=30 - -```json -{ "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true } -``` - -Kurs: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1). Bila `valid: false`, tampilkan `reason`. - -### POST /customer/wallet/exchange - -Body `{ "coins": 30, "pin": "482913" }`. Response: - -```json -{ - "group_id": "…", - "coins": 30, - "points": 9, - "coin_amount": 10, - "point_amount": 3, - "lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }], - "coin_balance": 5, - "point_balance": 9, - "replayed": false -} -``` - -`coins` harus kelipatan `coin_amount`; jumlah yang salah ditolak `304` sebelum PIN dicek. Exchange tidak bisa dibatalkan. EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (lihat `lots`). - -### GET /customer/wallet/transfer/recipient?phone=081234561234 - -```json -{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" } -``` - -Nomor di luar organisasi atau tidak terdaftar → `404`. Diri sendiri, customer walk-in, atau nonaktif → `304`. - -### POST /customer/wallet/transfer - -Body `{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" }`. Response: - -```json -{ - "group_id": "…", - "currency": "POINT", - "amount": 120, - "recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }, - "lots": [ - { "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" }, - { "amount": 20, "expires_at": null } - ], - "balance": 30, - "replayed": false -} -``` - -`currency` = `POINT` atau `COIN`. Batas organisasi (transfer aktif, minimal, maksimal per transaksi, batas harian per currency yang reset tengah malam WIB) ditolak `304` sebelum PIN dicek. Transfer final. Saldo membawa tanggal kedaluwarsa aslinya ke penerima (`lots`), dan penerima mendapat push `WALLET_TRANSFER_IN`. - -### POST /customer/spin - -Body `{ "spin_id": "" }`. Memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1). - -```json -{ - "game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" }, - "prize_won": { "id": "…", "name": "Voucher 10rb" }, - "coins_remaining": 7 -} -``` - -EnakCoin kurang, game nonaktif, atau hadiah baru saja habis → `304`, tidak ada EnakCoin yang terpotong. - -## POS: pembayaran EnakPoint - -Kasir memakai endpoint pembayaran yang sudah ada dengan payment method bertipe `point`, disetujui customer lewat kode bayar dari aplikasinya; PIN tidak pernah diketik di perangkat kasir. - -| Method | Path | Keterangan | -| --- | --- | --- | -| GET | `/orders/:id/point-payment/preview` | Batas pembayaran EnakPoint untuk order ini | -| POST | `/payments` | Bayar dengan method EnakPoint (`points` + `payment_code`) | -| POST | `/payments/:id/refund` | Refund pembayaran EnakPoint, kembali sebagai EnakPoint | - -1. Customer membuat kode di aplikasi (`POST /customer/wallet/payment-code`) dan menunjukkan angka atau QR-nya. -2. POS memanggil preview untuk tombol "pakai maksimal". -3. POS memanggil `POST /payments` dengan kode tersebut. Sisa tagihan dibayar dengan method lain seperti biasa. - -### GET /orders/:id/point-payment/preview - -```json -{ - "order_id": "…", - "customer_id": "…", - "eligible": true, - "point_balance": 12500, - "point_value": 1, - "remaining_amount": 87500, - "min_payment_points": 1, - "max_payment_percent": 100, - "max_points": 12500, - "max_amount": 12500 -} -``` - -Bila `eligible: false`, `reason` menjelaskan kenapa (order walk-in, outlet tidak menerima EnakPoint, saldo di bawah minimal, dst.). Batas yang dipakai: - -``` -batas_rupiah = min(sisa_tagihan, total × max_payment_percent / 100 − sudah_dibayar_EnakPoint) -maks_point = min(saldo, floor(batas_rupiah / point_value)) -``` - -### POST /payments - -Header `X-Idempotency-Key` wajib. - -```json -{ - "order_id": "…", - "payment_method_id": "", - "points": 12500, - "payment_code": "482913" -} -``` - -- `amount` tidak perlu dikirim; backend menghitung `points × point_value` dan tidak pernah melebihi sisa tagihan (tidak ada kembalian). -- `payment_code` boleh angka yang diketik atau hasil scan QR apa adanya (`enakpoint:482913`). -- Response pembayaran membawa `points_used` dan `point_value` untuk struk; response order membawa `points_earned` dan `coins_earned`. -- Ditolak `304` bila: order tanpa customer atau walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar batas, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method EnakPoint dipakai sebagai split. Kode terpakai begitu diterima; bila pembayaran lalu ditolak, minta kode baru. -- Method EnakPoint dibuat otomatis per organisasi, tidak bisa dihapus atau diubah tipenya, dan tidak muncul di daftar method `?outlet_id=` bila outlet tidak menerima EnakPoint. - -### Void dan refund - -- **Void order:** semua EnakPoint yang dipakai kembali sebagai EnakPoint. -- **`POST /payments/:id/refund` pada pembayaran EnakPoint:** kembali `floor(rupiah_direfund / point_value_saat_bayar)`; sisa di bawah 1 EnakPoint hangus. -- **Refund order ke tunai/method lain** hanya sebesar bagian non-EnakPoint; mencoba merefund bagian EnakPoint secara tunai ditolak `304`. -- EnakPoint yang kembali memakai tanggal kedaluwarsa asal, minimal 7 hari sejak refund. Earning order ikut ditarik; bila saldo sudah terpakai, ditarik sebanyak yang ada dan refund tetap jalan. - -## Dashboard - -Semua endpoint dashboard butuh role Admin atau Manager, dan semuanya dibatasi ke organisasi user yang login. Rincian layar ada di [`backoffice-enakpoint.md`](./backoffice-enakpoint.md). - -| Method | Path | Keterangan | -| --- | --- | --- | -| GET, PUT | `/outlets/:outlet_id/loyalty-settings` | Earning dan penerimaan EnakPoint per outlet | -| GET, PUT | `/marketing/loyalty-settings` | Nilai EnakPoint, kurs, transfer, kedaluwarsa (`?dry_run=true` untuk preview) | -| GET | `/marketing/loyalty-settings/history` | Riwayat perubahan setting (`page`, `limit`, `outlet_id`) | -| GET | `/marketing/customers/:id/wallet` | Saldo, lot aktif, riwayat dengan nama asli | -| POST | `/marketing/customers/:id/wallet/adjust` | Koreksi saldo manual | -| GET | `/marketing/wallet-transactions/:id/trace` | Telusuri asal saldo per butir | -| DELETE | `/marketing/customers/:id/pin` | Hapus PIN customer | -| GET | `/marketing/customers/:id/security-events` | Log keamanan PIN (`page`, `limit`) | - -Pada kedua `PUT` setting, field yang tidak dikirim tetap memakai nilai sekarang; field yang tidak dikenal ditolak. - -### /outlets/:outlet_id/loyalty-settings - -```json -{ - "point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null }, - "coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null }, - "point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 } -} -``` - -Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `max_payment_percent` 0–100. - -### /marketing/loyalty-settings - -```json -{ - "point_value": 1, - "exchange": { "coin_amount": 1, "point_amount": 1 }, - "transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null }, - "point_expiry": { - "enabled": false, - "mode": "FIXED_DATE", - "fixed_dates": ["12-31"], - "grace_months": 3, - "period": 12, - "unit": "MONTH", - "end_of_month": false, - "reminder_days": 7 - }, - "coin_expiry": { "…": "sama dengan point_expiry" } -} -``` - -| Field kedaluwarsa | Dipakai mode | Nilai | -| --- | --- | --- | -| `mode` | – | `FIXED_DATE` (hangus di tanggal tetap tiap tahun) atau `ROLLING` (umur sejak didapat) | -| `fixed_dates` | `FIXED_DATE` | `MM-DD`, boleh lebih dari satu; `02-29` ditolak | -| `grace_months` | `FIXED_DATE` | 0–24; saldo yang didapat kurang dari ini sebelum tanggal hangus ikut ke tanggal berikutnya | -| `period`, `unit` | `ROLLING` | ≥ 1, `DAY` atau `MONTH` | -| `end_of_month` | `ROLLING` | Dibulatkan ke akhir bulan | -| `reminder_days` | keduanya | Hari sebelum hangus untuk pengingat; 0 = tanpa pengingat | - -Response menambahkan: - -- `impact`: saldo beredar dan nilai rupiahnya sebelum/sesudah perubahan `point_value` atau kurs. -- `expiry_preview`: `{ "point", "coin" }`, kapan saldo yang didapat sekarang kedaluwarsa (`null` = tidak). -- `expiry_activations`: bila perubahan ini menyalakan kedaluwarsa pertama kali, `[{ "currency", "lots", "amount", "expires_at" }]` saldo lama yang ikut diberi tanggal. -- `changes` dan `dry_run`. - -### POST /marketing/customers/:id/wallet/adjust - -```json -{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" } -``` - -`amount` bertanda dan tidak boleh 0; `reason` wajib. Pengurangan yang melebihi saldo ditolak `304`. Response: `{ "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }`. - -### GET /marketing/wallet-transactions/:id/trace - -```json -{ - "transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "PAYMENT", "amount": -30, "…": "…" }, - "lots": [ - { - "amount": 30, - "chain": [ - { "lot": { "id": "…", "expires_at": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" } } }, - { "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "reference_type": "ORDER", "description": "Belanja #ORD-1", "customer": { "name": "Anita" } } } - ] - } - ] -} -``` - -Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat. Tiap `chain` mundur lewat transfer, exchange, atau refund sampai lot pertama dari `EARN`, `ADJUSTMENT`, atau `MIGRATION`. - -### PIN customer - -`DELETE /marketing/customers/:id/pin` dengan `{ "reason": "…" }` memaksa customer membuat PIN baru lewat OTP; admin tidak bisa membuat, mengganti, atau melihat PIN. `security-events` mengembalikan `PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`, `PIN_REMOVED_BY_ADMIN` beserta waktu, IP, dan perangkat. - -## Referensi - -### Tipe mutasi (`type`) - -| `type` | Arah | Arti | `source` / `destination` | -| --- | --- | --- | --- | -| `EARN` | + | Didapat dari order lunas | `ORDER` | -| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` | -| `PAYMENT` | − | Membayar order (EnakPoint saja) | `PAYMENT` | -| `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` | -| `EXCHANGE_OUT` | − | EnakCoin ditukar | `WALLET_TX` (baris `EXCHANGE_IN`) | -| `EXCHANGE_IN` | + | EnakPoint hasil tukar | `WALLET_TX` (baris `EXCHANGE_OUT`) | -| `TRANSFER_OUT` | − | Dikirim ke customer lain | `WALLET_TX` (baris `TRANSFER_IN`) | -| `TRANSFER_IN` | + | Diterima dari customer lain | `WALLET_TX` (baris `TRANSFER_OUT`) | -| `GAME_SPEND` | − | Main game (EnakCoin saja) | `GAME_PLAY` | -| `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` | -| `ADJUSTMENT` | + / − | Koreksi admin | `USER` | -| `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` | - -### Notifikasi push (FCM) - -Semua nilai `data` berupa string. Push hanya sampai ke device yang terdaftar lewat `PUT /customer/devices`. - -| `data.type` | Kapan | Isi `data` lainnya | -| --- | --- | --- | -| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` | -| `WALLET_EXPIRING` | `reminder_days` hari sebelum hangus, sekali per tanggal | `currency`, `amount`, `expiry_date` | -| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` | -| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339, UTC) | - -### Endpoint dan field deprecated - -Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi pindah. - -| Lama | Pengganti | -| --- | --- | -| `GET /customer/points` | `GET /customer/wallet` → `point_balance` | -| `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` | -| `total_points`, `total_tokens`, `points_history`, `tokens_history`, `last_updated` di `/customer/wallet` | `point_balance`, `coin_balance`, `recent_transactions` | -| `token_used`, `tokens_remaining` di response game | `coins_used`, `coins_remaining` | -| `sort_by=token_used` di daftar game play | `sort_by=coins_used` | - -Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md). diff --git a/docs/backoffice-enakpoint.md b/docs/backoffice-enakpoint.md deleted file mode 100644 index 599eb34..0000000 --- a/docs/backoffice-enakpoint.md +++ /dev/null @@ -1,305 +0,0 @@ -# Backoffice EnakPoint & EnakCoin - -30 Sep 2026 - -Backoffice perlu tujuh layar untuk mengelola program loyalitas: setting per outlet, setting per organisasi (termasuk kedaluwarsa), wallet customer, telusuri mutasi, PIN customer, riwayat setting, dan biaya main game. - -## Layar yang perlu dibuat - -Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin atau Manager, dan otomatis dibatasi ke organisasi user tersebut. Data customer atau outlet organisasi lain dijawab `404`. - -| Layar | Endpoint | Tempat di menu | -| --- | --- | --- | -| Setting loyalitas outlet | `GET` / `PUT /outlets/:outlet_id/loyalty-settings` | Outlet → detail outlet → tab Loyalitas | -| Setting loyalitas organisasi | `GET` / `PUT /marketing/loyalty-settings` (+ `?dry_run=true`) | Marketing → Loyalitas → Pengaturan | -| Riwayat perubahan setting | `GET /marketing/loyalty-settings/history` | Marketing → Loyalitas → Riwayat | -| Wallet customer | `GET /marketing/customers/:id/wallet`, `POST …/wallet/adjust` | Customer → detail customer → tab Wallet | -| Telusuri mutasi | `GET /marketing/wallet-transactions/:id/trace` | Dibuka dari baris riwayat wallet | -| PIN & keamanan customer | `DELETE /marketing/customers/:id/pin`, `GET …/security-events` | Customer → detail customer → tab Keamanan | -| Biaya main game | `PUT` game yang sudah ada, `metadata.coin_cost` | Marketing → Game → edit game | - -Penempatan menu di atas adalah usulan; sesuaikan dengan struktur backoffice yang ada. - -**Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang bisa membayar order; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke EnakPoint. Nilai rupiah EnakPoint selalu ditulis "setara potongan Rp …", tidak pernah "saldo Rp …", karena saldo tidak bisa dicairkan. - -**Format response.** Sukses `{ "success": true, "data": … }`; gagal `{ "success": false, "errors": [{ "code", "entity", "cause" }] }`. Tampilkan `cause` sebagai pesan (lihat bagian Pesan error). - -## Setting loyalitas outlet - -Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari order, dan apakah outlet menerima pembayaran EnakPoint. Semua nilai default mati sampai owner menyalakannya. - -`GET /outlets/:outlet_id/loyalty-settings` → isi form. `PUT` ke path yang sama dengan objek yang sama untuk menyimpan; field yang tidak dikirim tetap, field tak dikenal ditolak. - -```json -{ - "point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null }, - "coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null }, - "point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 } -} -``` - -| Field | Label usulan | Tipe | Default | Validasi | -| --- | --- | --- | --- | --- | -| `point.enabled` / `coin.enabled` | Beri EnakPoint / EnakCoin | toggle | mati | – | -| `earn_per_amount` | Setiap belanja Rp … | Rp | 100 (point), 25.000 (coin) | > 0 | -| `earn_value` | … mendapat | angka | 1 | ≥ 0 | -| `min_order_amount` | Minimal belanja | Rp | 0 | ≥ 0 | -| `max_per_order` | Maksimal per order | angka, boleh kosong | kosong = tanpa batas | ≥ 0 | -| `point_payment.accept_payment` | Terima pembayaran EnakPoint | toggle | mati | – | -| `min_payment_points` | Minimal EnakPoint per pembayaran | angka | 1 | ≥ 1 | -| `max_payment_percent` | Maksimal porsi order dibayar EnakPoint | % | 100 | 0–100 | - -**Cashback efektif.** Response membawa `point_cashback_percent` dan `point_value`. Tampilkan persentase di samping field earning EnakPoint, mis. "setara cashback 1%", dan hitung ulang di sisi klien saat owner mengetik: `earn_value × point_value ÷ earn_per_amount × 100`. Tujuannya agar owner tidak salah membaca skala (1 per Rp 100 bukan 1 per Rp 1). - -**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak, dan bagian yang dibayar EnakPoint tidak ikut dihitung. - -Setelah `PUT`, response membawa `changes` (key yang berubah); tampilkan toast singkat, mis. "2 pengaturan disimpan". Mematikan `accept_payment` langsung menyembunyikan method EnakPoint di kasir outlet itu. - -## Setting loyalitas organisasi - -Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan kedaluwarsa berlaku sama untuk semua outlet, jadi diatur sekali per organisasi. Mengubah nilai EnakPoint atau kurs langsung mengubah daya beli semua saldo customer, jadi layar ini wajib menampilkan dampaknya sebelum disimpan. - -```json -{ - "point_value": 1, - "exchange": { "coin_amount": 1, "point_amount": 1 }, - "transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null }, - "point_expiry": { "…": "lihat bagian kedaluwarsa" }, - "coin_expiry": { "…": "lihat bagian kedaluwarsa" } -} -``` - -| Field | Label usulan | Default | Validasi | -| --- | --- | --- | --- | -| `point_value` | Nilai 1 EnakPoint (Rp) | 1 | ≥ 1 | -| `exchange.coin_amount` : `exchange.point_amount` | Kurs tukar: … EnakCoin = … EnakPoint | 1 : 1 | keduanya ≥ 1 | -| `transfer.enabled` | Izinkan transfer antar customer | aktif | – | -| `transfer.min_amount` | Minimal per transfer | 1 | ≥ 1 | -| `transfer.max_per_transaction` | Maksimal per transfer | kosong = tanpa batas | ≥ 1 | -| `transfer.daily_limit` | Batas harian per customer | kosong = tanpa batas | ≥ 1, dihitung per currency, reset tengah malam WIB | - -### Alur simpan - -1. Owner mengubah form. -2. Tombol Simpan memanggil `PUT /marketing/loyalty-settings?dry_run=true` dengan objek yang diubah. Tidak ada yang tersimpan. -3. Bila `changes` kosong, beri tahu "tidak ada perubahan" dan berhenti. -4. Tampilkan dialog konfirmasi berisi `changes`, `impact` (bila `point_value` atau kurs berubah), dan `expiry_activations` (bila ada, lihat bagian kedaluwarsa). -5. Konfirmasi memanggil `PUT` yang sama tanpa `dry_run`. - -### Dialog dampak - -`impact` berisi saldo beredar organisasi dan nilainya sebelum/sesudah: - -| Field `impact` | Tampilkan sebagai | -| --- | --- | -| `outstanding_points` | EnakPoint beredar | -| `point_rupiah_before` → `point_rupiah_after` | Setara potongan Rp … → Rp … | -| `outstanding_coins` | EnakCoin beredar | -| `coins_as_points_before` → `coins_as_points_after` | Bila semua ditukar: … EnakPoint → … EnakPoint | -| `coin_rupiah_before` → `coin_rupiah_after` | Setara potongan Rp … → Rp … | - -Contoh kalimat: "Menaikkan nilai EnakPoint dari Rp 1 ke Rp 2 membuat 1.250.000 EnakPoint yang beredar setara potongan Rp 2.500.000 (sebelumnya Rp 1.250.000)." Perubahan hanya berlaku ke depan: pembayaran, refund, dan exchange yang sudah terjadi memakai nilai saat itu. - -## Pengaturan kedaluwarsa - -Kedaluwarsa diatur terpisah untuk EnakPoint (`point_expiry`) dan EnakCoin (`coin_expiry`) dengan salah satu dari dua model; defaultnya mati, dan bila dinyalakan defaultnya hangus setiap 31 Desember. - -```json -"point_expiry": { - "enabled": true, - "mode": "FIXED_DATE", - "fixed_dates": ["12-31"], - "grace_months": 3, - "period": 12, - "unit": "MONTH", - "end_of_month": false, - "reminder_days": 7 -} -``` - -| Field | Tampil saat | Label usulan | Validasi | -| --- | --- | --- | --- | -| `enabled` | selalu | Saldo bisa kedaluwarsa | – | -| `mode` | aktif | Model: Tanggal tetap / Sejak didapat | `FIXED_DATE` atau `ROLLING` | -| `fixed_dates` | `FIXED_DATE` | Tanggal hangus setiap tahun | minimal satu, format `MM-DD`, `02-29` ditolak | -| `grace_months` | `FIXED_DATE` | Periode tanggung (bulan) | 0–24, default 3 | -| `period` + `unit` | `ROLLING` | Berlaku selama … hari/bulan | period ≥ 1, `DAY` atau `MONTH` | -| `end_of_month` | `ROLLING` | Bulatkan ke akhir bulan | – | -| `reminder_days` | aktif | Ingatkan customer … hari sebelumnya | ≥ 0, 0 = tanpa pengingat | - -**Tanggal tetap (`FIXED_DATE`).** Semua saldo hangus di tanggal yang sama, mis. 31 Desember, atau 30 Juni dan 31 Desember untuk dua kali setahun. Saldo yang didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya, jadi saldo yang didapat 1 Oktober dengan tanggung 3 bulan hangus 31 Desember tahun depan. Untuk input `fixed_dates`, pakai pemilih tanggal+bulan tanpa tahun. - -**Sejak didapat (`ROLLING`).** Tiap saldo berlaku `period` hari atau bulan sejak masuk, mis. 12 bulan. Dengan `end_of_month`, saldo yang didapat 14 Maret 2026 hangus 31 Maret 2027. - -**Preview.** Response `GET`, `PUT`, dan dry run membawa `expiry_preview.point` dan `.coin`: kapan saldo yang didapat sekarang akan kedaluwarsa (`null` = tidak). Tampilkan di bawah form: "EnakPoint yang didapat hari ini kedaluwarsa pada 31 Des 2026." Karena dihitung dari nilai yang dikirim, dry run bisa dipakai untuk memperbarui preview saat owner mengubah pilihan. - -**Menyalakan pertama kali.** Saldo lama yang belum punya tanggal ikut diberi tanggal, dengan masa berlaku penuh: tanggal hangus kedua berikutnya (`FIXED_DATE`) atau satu periode sejak hari ini (`ROLLING`). Dry run mengembalikan `expiry_activations`; tampilkan di dialog konfirmasi dengan kalimat tegas, mis. "1.250.000 EnakPoint milik customer yang ada sekarang akan kedaluwarsa pada 31 Des 2027. Tindakan ini tidak bisa dibatalkan dengan mematikan kedaluwarsa." - -| Field `expiry_activations[]` | Arti | -| --- | --- | -| `currency` | `POINT` atau `COIN` | -| `lots` | Jumlah paket saldo yang diberi tanggal | -| `amount` | Total saldo yang diberi tanggal | -| `expires_at` | Tanggal kedaluwarsanya | - -**Aturan lain yang perlu dijelaskan di layar:** - -- Mengubah model atau masa berlaku hanya berlaku untuk saldo yang masuk setelahnya. -- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal. -- Saldo yang ditransfer atau ditukar membawa tanggal kedaluwarsa aslinya. -- Saldo hangus tanpa kompensasi apa pun. Customer mendapat pengingat push `reminder_days` hari sebelumnya dan notifikasi saat hangus. - -## Wallet customer - -Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo dan asal-usulnya, mengoreksi saldo, dan menelusuri satu mutasi sampai ke order asalnya. - -### Saldo, lot, dan riwayat - -`GET /marketing/customers/:id/wallet?page=1&limit=20¤cy=POINT&type=PAYMENT,EARN&from=2026-09-01&to=2026-09-30` (semua query opsional, sama seperti riwayat di aplikasi customer) - -```json -{ - "customer": { "id": "…", "name": "Budi Santoso", "phone": "081234561234" }, - "point_balance": 12650, - "coin_balance": 8, - "spendable_point_balance": 12500, - "spendable_coin_balance": 8, - "lots": [ - { "id": "…", "currency": "POINT", "original_amount": 875, "remaining_amount": 875, "expires_at": "2026-12-31T23:59:59+07:00", "expired": false, "source_transaction_id": "…", "origin_lot_id": null, "created_at": "…" } - ], - "transactions": { - "data": [ - { - "id": "…", "currency": "POINT", "type": "TRANSFER_OUT", "amount": -120, "balance_after": 12650, - "description": "Transfer ke An*** (08**-****-5678)", - "destination": { "type": "WALLET_TX", "id": "…" }, - "counterparty": { "id": "…", "name": "Anita Rahma" }, - "created_by": null, "outlet": null, "reason": null, "metadata": {}, - "created_at": "…" - } - ], - "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } - } -} -``` - -- **Saldo:** tampilkan `spendable_*` sebagai saldo utama. `point_balance` / `coin_balance` bisa sedikit lebih besar selama ada lot yang sudah lewat tanggal tapi belum diproses job kedaluwarsa (paling lama sekitar 15 menit). -- **Lot:** tabel paket saldo yang masih berisi, urut dari yang paling cepat kedaluwarsa. Beri tanda untuk `expired: true`. -- **Riwayat:** sama dengan riwayat customer, ditambah nama asli yang disamarkan untuk customer: `counterparty` (lawan transfer), `created_by` (admin pelaku adjustment atau kasir penerima pembayaran), `outlet`, `reason`, dan `metadata` (kurs, nilai EnakPoint yang dibekukan, shortfall). - -### Adjustment manual - -`POST /marketing/customers/:id/wallet/adjust` - -```json -{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-7f3c" } -``` - -| Field | Aturan | -| --- | --- | -| `currency` | `POINT` atau `COIN` | -| `amount` | Bertanda, tidak boleh 0. Positif menambah, negatif mengurangi | -| `reason` | Wajib; tampil di riwayat customer sebagai "Koreksi oleh admin: …" | -| `idempotency_key` | Opsional tapi disarankan: buat satu nilai saat dialog dibuka, supaya klik ganda tidak mengoreksi dua kali | - -Pengurangan yang melebihi saldo yang bisa dipakai ditolak `304`. Adjustment tambah mengikuti aturan kedaluwarsa organisasi. Response: `{ "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }`. Beri catatan di dialog bahwa adjustment tidak disertai pembayaran uang, sehingga alasan tidak boleh "pencairan". - -### Telusuri mutasi - -Dari baris riwayat mana pun, tombol Telusuri memanggil `GET /marketing/wallet-transactions/:id/trace`. - -```json -{ - "transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "currency": "POINT", "type": "PAYMENT", "amount": -30, "description": "Bayar #ORD-0456 di Outlet Kemang (Rp 30)", "reference_type": "PAYMENT", "reference_id": "…", "created_at": "…" }, - "lots": [ - { - "amount": 30, - "chain": [ - { "lot": { "id": "…", "expires_at": "…", "origin_lot_id": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" }, "description": "Transfer dari An*** (08**-****-5678)" } }, - { "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "customer": { "name": "Anita Rahma" }, "reference_type": "ORDER", "reference_id": "…", "description": "Belanja #ORD-1 di Outlet Kemang" } } - ] - } - ] -} -``` - -Tampilkan tiap `lots[]` sebagai rantai dari atas ke bawah: jumlah yang lewat lot itu, lalu setiap langkah `chain` dengan pemilik, tipe, dan deskripsinya. Langkah terakhir selalu `EARN`, `ADJUSTMENT`, atau `MIGRATION`; bila `reference_type` = `ORDER`, jadikan tautan ke detail order. Mutasi keluar menampilkan lot yang dipakai; mutasi masuk menampilkan lot yang dibuatnya. - -## PIN, riwayat setting, game, dan method EnakPoint - -### PIN & keamanan customer - -Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aksi adalah menghapusnya, misalnya bila customer kehilangan akses, sehingga customer harus membuat PIN baru lewat OTP di aplikasi. - -- `DELETE /marketing/customers/:id/pin` dengan body `{ "reason": "Customer ganti nomor HP" }`. `reason` wajib. Tampilkan dialog konfirmasi dengan input alasan. -- `GET /marketing/customers/:id/security-events?page=1&limit=20` untuk tab Keamanan: - -```json -{ - "data": [ - { "id": "…", "event": "PIN_LOCKED", "actor_user": null, "reason": null, "ip_address": "103.10.0.7", "user_agent": "EnakApp/2.4 (Android 14)", "created_at": "…" } - ], - "pagination": { "page": 1, "limit": 20, "total_count": 5, "total_pages": 1 } -} -``` - -| `event` | Label usulan | -| --- | --- | -| `PIN_SET` | PIN dibuat | -| `PIN_CHANGED` | PIN diganti | -| `PIN_RESET` | PIN direset lewat OTP (transfer ditahan 24 jam) | -| `PIN_FAILED` | PIN salah dimasukkan | -| `PIN_LOCKED` | PIN terkunci 30 menit | -| `PIN_REMOVED_BY_ADMIN` | PIN dihapus admin (`actor_user`, `reason` terisi) | - -### Riwayat perubahan setting - -`GET /marketing/loyalty-settings/history?page=1&limit=20` untuk setting organisasi; tambah `&outlet_id=…` untuk riwayat satu outlet. - -```json -{ "id": "…", "organization_id": "…", "outlet_id": null, "key": "loyalty.point.value", "old_value": "1", "new_value": "2", "changed_by": "…", "created_at": "…" } -``` - -`old_value` `null` berarti sebelumnya masih nilai default. Tampilkan `key` dengan label yang sama seperti di form (mis. `loyalty.point.value` → "Nilai 1 EnakPoint"), dan `changed_by` sebagai nama user. - -### Biaya main game - -Semua game (spin, raffle, minigame) memakai EnakCoin yang sama. Biaya per main diisi di `metadata.coin_cost` saat membuat atau mengedit game (`/marketing/games`): bilangan bulat ≥ 1, default 1 bila kosong. Nilai pecahan, 0, atau teks membuat game tidak bisa dimainkan. Karena `metadata` dikirim utuh, pertahankan key metadata lain saat menyimpan. Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint. - -### Method pembayaran EnakPoint - -Method "EnakPoint" (tipe `point`) dibuat otomatis untuk setiap organisasi. Di layar Payment Method (`/payment-methods`): - -- Tampilkan sebagai method sistem: tombol hapus dan pilihan ubah tipe disembunyikan; backend menolaknya (`304`). Nama boleh diganti. -- Tipe `point` tidak ditawarkan saat membuat method baru. -- Kasir hanya melihatnya di outlet yang menyalakan "Terima pembayaran EnakPoint". - -Di laporan per payment method, EnakPoint tampil terpisah dan **tidak** dihitung sebagai kas masuk. - -## Pesan error dan checklist - -| `code` | HTTP | Kapan terjadi di backoffice | Yang ditampilkan | -| --- | --- | --- | --- | -| `303`, `310` | 400 | Body tidak valid, field tak dikenal di `PUT` setting, UUID salah | Pesan umum "Data tidak valid" + `cause` untuk developer | -| `304` | 400 | Nilai di luar batas, adjustment melebihi saldo, alasan kosong, hapus/ubah method EnakPoint | `cause` di dekat field atau di toast | -| `404` | 404 | Customer, outlet, atau mutasi bukan milik organisasi ini | "Data tidak ditemukan" | -| `900` | 500 | Kesalahan server | "Terjadi kesalahan, coba lagi" | - -Pesan `cause` saat ini berbahasa Inggris, mis. `invalid loyalty settings: loyalty.point.earn_per_amount must be at least 1`. Untuk validasi form, lebih baik cek batasnya di sisi klien (tabel di tiap bagian) dan tampilkan `cause` hanya sebagai cadangan. - -### Checklist rilis - -- [ ] Form setting outlet menampilkan cashback efektif dan contoh earning. -- [ ] Setting organisasi selalu lewat dry run dan dialog konfirmasi sebelum disimpan. -- [ ] Dialog konfirmasi menampilkan `impact` saat nilai EnakPoint atau kurs berubah. -- [ ] Dialog konfirmasi menampilkan `expiry_activations` saat kedaluwarsa dinyalakan pertama kali. -- [ ] Preview "yang didapat hari ini kedaluwarsa pada …" tampil di bawah pengaturan kedaluwarsa. -- [ ] Wallet customer menampilkan saldo yang bisa dipakai, lot, dan riwayat dengan nama asli. -- [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`. -- [ ] Tombol Telusuri ada di setiap baris riwayat. -- [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log. -- [ ] Method EnakPoint tampil sebagai method sistem. -- [ ] Form game punya input `coin_cost`. -- [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …". - -Pembayaran EnakPoint belum boleh dirilis ke outlet sebelum tinjauan keuangan (N2) dan legal (N3) selesai, dan transfer menunggu tinjauan legal (N3). Layar backoffice boleh disiapkan lebih dulu. diff --git a/docs/integration-enakpoint.md b/docs/integration-enakpoint.md deleted file mode 100644 index 1b5e816..0000000 --- a/docs/integration-enakpoint.md +++ /dev/null @@ -1,635 +0,0 @@ -# Integrasi EnakPoint & EnakCoin — Customer App, POS & Dashboard - -**Migrasi:** `000090`–`000097` · **Base URL:** `/api/v1` · **Kompatibilitas:** endpoint -lama tetap jalan sebagai alias (lihat §8) - -Panduan untuk memakai saldo loyalitas dari sisi klien. Alasan di balik setiap aturan -ada di [`prd-point-coin.md`](./prd-point-coin.md). - ---- - -## 1. Konsep inti - -| | EnakPoint (`POINT`) | EnakCoin (`COIN`) | -|---|---|---| -| Didapat dari | Order lunas (per outlet), adjustment admin, exchange | Order lunas (per outlet), adjustment admin | -| Dipakai untuk | **Membayar order** | **Main game**, ditukar ke EnakPoint | -| Bisa ditransfer | Ya | Ya | -| Bisa kedaluwarsa | Ya, bila diaktifkan owner | Ya, bila diaktifkan owner | - -Aturan yang berlaku di seluruh dokumen ini: - -1. **Semua jumlah bilangan bulat.** Tidak ada "setengah EnakPoint". -2. **Saldo tidak pernah jadi uang.** Tidak ada pencairan, tidak ada kembalian, dan - bagian order yang dibayar EnakPoint hanya bisa kembali sebagai EnakPoint. Tampilkan - nilai rupiahnya sebagai **"setara potongan Rp …"**, bukan "saldo Rp …". -3. **Semua aksi customer yang memindahkan saldo butuh PIN 6 digit** (§3): bayar, - buat kode bayar, exchange, transfer. Main game tidak butuh PIN. -4. **Wallet milik customer di satu organisasi.** Saldo berlaku di semua outlet - organisasi itu. Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan - kedaluwarsa diatur per organisasi; earning dan penerimaan pembayaran per outlet. -5. **Setiap mutasi tercatat** di riwayat beserta asal atau tujuannya, dan tidak pernah - dihapus. Koreksi muncul sebagai baris baru. - -### Format response - -Semua endpoint memakai amplop yang sama: - -```json -{ "success": true, "data": { … }, "errors": null } -``` - -```json -{ - "success": false, - "data": null, - "errors": [{ "code": "304", "entity": "wallet_service", "cause": "wallet move refused: not enough EnakCoin" }] -} -``` - -| `code` | HTTP | Arti | -|---|---|---| -| `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format | -| `304` | 400 | Permintaan ditolak aturan bisnis; `cause` menjelaskan alasannya | -| `404` | 404 | Tidak ditemukan (juga dipakai untuk data milik customer/organisasi lain) | -| `429` | 429 | Terlalu cepat meminta ulang (OTP) | -| `PIN_NOT_SET` | 403 | Customer belum membuat PIN | -| `PIN_INVALID` | 400 | PIN salah | -| `PIN_LOCKED` | 423 | PIN terkunci | -| `TRANSFER_BLOCKED` | 403 | Transfer ditahan setelah reset PIN | -| `900` | 500 | Kesalahan server | - ---- - -## 2. Customer app — saldo & riwayat - -Semua endpoint customer memakai header `Authorization: Bearer `. - -### 2.1 Saldo - -`GET /api/v1/customer/wallet` - -```json -{ - "point_balance": 12500, - "coin_balance": 8, - "point_value": 1, - "point_discount_value": 12500, - "nearest_expiring": { - "point": { "amount": 150, "date": "2026-12-31" }, - "coin": null - }, - "recent_transactions": [ … ] -} -``` - -- `point_balance` dan `coin_balance` adalah saldo yang **bisa dipakai sekarang**. -- `point_discount_value` = `point_balance × point_value`. Tampilkan sebagai - "setara potongan Rp 12.500". -- `nearest_expiring` bernilai `null` per currency bila tidak ada yang akan kedaluwarsa. -- `recent_transactions` berisi 5 mutasi terakhir dengan bentuk yang sama seperti §2.2. - -### 2.2 Riwayat - -`GET /api/v1/customer/wallet/transactions?page=1&limit=20¤cy=POINT&type=EARN,PAYMENT&from=2026-09-01&to=2026-09-30` - -Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipisah koma. -`from` / `to` tanggal WIB, inklusif. - -```json -{ - "data": [ - { - "id": "…", - "currency": "POINT", - "type": "EARN", - "amount": 875, - "balance_after": 12500, - "description": "Belanja #ORD-0123 di Outlet Kemang", - "source": { "type": "ORDER", "id": "…" }, - "outlet_id": "…", - "expires_at": "2026-12-31T23:59:59+07:00", - "lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }], - "created_at": "2026-09-30T12:01:00Z" - } - ], - "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } -} -``` - -- `amount` bertanda: positif menambah saldo, negatif mengurangi. -- Penambahan punya `source`, pengurangan punya `destination`. Keduanya berbentuk - `{ type, id }` dan menunjuk hal yang bisa dibuka di detail (order, pembayaran, game - play, dst.). -- `description` sudah siap tampil dan tidak berubah walau nama outlet atau customer - berubah belakangan. Nama lawan transfer sudah disamarkan. -- Dua baris exchange atau transfer berbagi `group_id` yang sama. - -| `type` | Arah | Arti | `source` / `destination` | -|---|---|---|---| -| `EARN` | + | Didapat dari order lunas | `ORDER` | -| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` | -| `PAYMENT` | − | Membayar order | `PAYMENT` | -| `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` | -| `EXCHANGE_OUT` / `EXCHANGE_IN` | − / + | Tukar EnakCoin ke EnakPoint | `WALLET_TX` (baris pasangannya) | -| `TRANSFER_OUT` / `TRANSFER_IN` | − / + | Transfer antar customer | `WALLET_TX` (baris pasangannya) | -| `GAME_SPEND` | − | Main game | `GAME_PLAY` | -| `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` | -| `ADJUSTMENT` | + / − | Koreksi oleh admin | `USER` | -| `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` | - -### 2.3 Yang akan kedaluwarsa - -`GET /api/v1/customer/wallet/expiring` - -```json -{ - "point": [ - { "amount": 150, "date": "2026-10-31" }, - { "amount": 200, "date": "2026-12-31" } - ], - "coin": [] -} -``` - -Dikelompokkan per tanggal (WIB), paling dekat lebih dulu. Saldo bisa dipakai sampai -akhir hari tanggal itu. Daftar kosong berarti tidak ada yang akan kedaluwarsa. - -### 2.4 Notifikasi push (FCM) - -Aplikasi mendaftarkan token FCM-nya **setelah login dan setiap kali FCM memberi token -baru**: - -`PUT /api/v1/customer/devices` - -```json -{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" } -``` - -`platform`: `android`, `ios`, atau `web` (opsional). Saat logout, panggil -`DELETE /api/v1/customer/devices/:device_id` supaya HP itu tidak lagi menerima -notifikasi customer tersebut. Satu token hanya milik satu customer: bila customer lain -login di HP yang sama dan mendaftarkan token yang sama, customer sebelumnya otomatis -tidak menerima notifikasi di HP itu lagi. - -Push yang dikirim, dibedakan lewat `data.type`: - -| `data.type` | Kapan | Isi `data` lainnya | -|---|---|---| -| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` | -| `WALLET_EXPIRING` | `reminder_days` hari sebelum saldo kedaluwarsa, sekali per tanggal | `currency`, `amount`, `expiry_date` | -| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` | -| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339, UTC) | - -Semua nilai di `data` berupa string, sesuai aturan FCM. - ---- - -## 3. Customer app — PIN - -PIN 6 digit, terpisah dari password login, dikirim sebagai **string** supaya angka nol -di depan tidak hilang. PIN tidak pernah dikembalikan di response. - -### 3.1 Cek status - -`GET /api/v1/customer/pin/status` - -```json -{ "has_pin": true, "locked_until": null, "transfer_blocked_until": null } -``` - -Minta customer membuat PIN saat pertama kali ia melakukan aksi yang butuh PIN -(`has_pin: false`), bukan saat registrasi. - -### 3.2 Membuat PIN pertama kali - -1. `POST /api/v1/customer/pin/otp` dengan `{ "purpose": "pin_setup" }`. OTP dikirim ke - nomor customer lewat WhatsApp. Response: `{ "purpose", "otp_token", "expires_at" }`. -2. `POST /api/v1/customer/pin` dengan - `{ "otp_token": "…", "otp_code": "123456", "pin": "482913", "confirm_pin": "482913" }`. - -PIN ditolak (`304`) bila bukan 6 digit, konfirmasinya beda, semua digit sama -(`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir -(`DDMMYY` / `YYMMDD`). Tampilkan `cause` apa adanya. Meminta OTP terlalu cepat -menghasilkan `429`. - -### 3.3 Mengganti dan mereset PIN - -- **Ganti:** `PUT /api/v1/customer/pin` dengan `{ "old_pin", "pin", "confirm_pin" }`. -- **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu - `POST /api/v1/customer/pin/reset` dengan body yang sama seperti §3.2. Reset juga - membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**; - pembayaran dan exchange tetap bisa. - -### 3.4 Menangani error PIN - -Setiap endpoint yang menerima `pin` bisa mengembalikan error PIN. Pada error ini `data` -**tidak** `null`: - -```json -{ - "success": false, - "data": { "code": "PIN_INVALID", "remaining_attempts": 3 }, - "errors": [{ "code": "PIN_INVALID", "entity": "customer_pin_service", "cause": "wrong PIN, 3 attempts left" }] -} -``` - -| `data.code` | Field tambahan | Yang ditampilkan aplikasi | -|---|---|---| -| `PIN_NOT_SET` | – | Arahkan ke pembuatan PIN (§3.2) | -| `PIN_INVALID` | `remaining_attempts` | "PIN salah, sisa 3 percobaan" | -| `PIN_LOCKED` | `locked_until` | "PIN terkunci sampai 14:30", tawarkan reset PIN | -| `TRANSFER_BLOCKED` | `transfer_blocked_until` | "Transfer bisa dilakukan lagi pada …" | - -Lima kali salah berturut-turut mengunci PIN selama 30 menit. Selama terkunci, PIN yang -benar pun ditolak. Penghitung disimpan di server, jadi tidak bisa diakali dengan -reinstall atau ganti HP. - ---- - -## 4. Membayar dengan EnakPoint - -Ada dua jalur. Keduanya memakai logika perhitungan yang sama. - -### 4.1 Batas pembayaran - -EnakPoint maksimal yang bisa dipakai untuk satu order: - -``` -batas_rupiah = min(sisa_tagihan, total_order × max_payment_percent / 100 − yang_sudah_dibayar_EnakPoint) -maks_point = min(saldo_customer, floor(batas_rupiah / point_value)) -``` - -Ditambah minimal `min_payment_points` per pembayaran. Nominal rupiah pembayaran selalu -`points × point_value` dan **tidak pernah melebihi sisa tagihan**, jadi tidak ada -kembalian. Sisa tagihan dibayar dengan method lain seperti biasa (split). - -### 4.2 POS — kode bayar dari aplikasi customer - -PIN **tidak pernah** diketik di perangkat kasir. Customer menyetujui di HP-nya sendiri: - -1. **Customer app:** `POST /api/v1/customer/wallet/payment-code` dengan `{ "pin": "482913" }`. - - ```json - { "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" } - ``` - - Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR. Kode berlaku **2 menit**, - sekali pakai, dan hanya untuk customer itu. Membuat kode baru membatalkan kode lama. - -2. **POS:** tampilkan batas untuk tombol "pakai maksimal": - - `GET /api/v1/orders/:id/point-payment/preview` - - ```json - { - "order_id": "…", - "customer_id": "…", - "eligible": true, - "point_balance": 12500, - "point_value": 1, - "remaining_amount": 87500, - "min_payment_points": 1, - "max_payment_percent": 100, - "max_points": 12500, - "max_amount": 12500 - } - ``` - - Bila `eligible: false`, `reason` menjelaskan kenapa (order walk-in, outlet tidak - menerima EnakPoint, saldo di bawah minimal, dst.). - -3. **POS:** bayar lewat endpoint pembayaran yang sudah ada, dengan payment method - bertipe `point`: - - `POST /api/v1/payments` (header `X-Idempotency-Key` wajib seperti pembayaran lain) - - ```json - { - "order_id": "…", - "payment_method_id": "", - "points": 12500, - "payment_code": "482913" - } - ``` - - `amount` tidak perlu dikirim; backend menghitungnya. `payment_code` boleh berupa - angka yang diketik kasir atau hasil scan QR apa adanya (`enakpoint:482913`). - -Response pembayaran membawa `points_used` dan `point_value` untuk struk, misalnya -"EnakPoint: 12.500 (Rp 12.500)". Jika pembayaran ini melunasi order, order menjadi -`completed`; jika belum, sisanya dibayar dengan method lain. - -Pembayaran ditolak (`304`, `cause` menjelaskan) bila: order tanpa customer atau -customer walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar -batas §4.1, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method -EnakPoint dipakai sebagai split (bayar bagian EnakPoint sebagai pembayaran tersendiri, -lalu split sisanya seperti biasa). Kode bayar dipakai habis begitu diterima, sebelum -batas dicek ulang; bila pembayaran lalu ditolak (misalnya saldo berubah), minta -customer membuat kode baru. - -**Method EnakPoint** dibuat otomatis untuk setiap organisasi dan tidak bisa dihapus -atau diubah tipenya (namanya boleh diganti). Daftar payment method yang dikirim -`?outlet_id=` tidak menampilkannya bila outlet itu tidak menerima EnakPoint. - -### 4.3 Customer app / self-order — bayar order sendiri - -`POST /api/v1/customer/orders/:id/pay-with-points` - -```json -{ "points": 12500, "pin": "482913" } -``` - -Hanya untuk order milik customer yang login; order lain dijawab `404`. Response sama -dengan response pembayaran di §4.2. - -### 4.4 Void dan refund - -- **Void order:** semua EnakPoint yang dipakai kembali ke customer sebagai EnakPoint. -- **Refund pembayaran EnakPoint** (`POST /api/v1/payments/:id/refund` pada pembayaran - EnakPoint): yang kembali `floor(rupiah_direfund / point_value_saat_bayar)`. Perubahan - nilai EnakPoint setelah pembayaran tidak mengubah jumlah yang kembali; sisa di bawah - 1 EnakPoint hangus. -- **Refund order ke tunai / method lain** hanya boleh sebesar bagian yang dibayar - dengan method lain. Bagian EnakPoint harus direfund lewat pembayaran EnakPoint-nya - sendiri; mencoba lewat tunai dijawab `304`. -- EnakPoint yang kembali mengikuti tanggal kedaluwarsa asalnya, tapi minimal 7 hari - sejak refund. -- EnakPoint dan EnakCoin yang didapat dari order ikut ditarik saat void/refund. Bila - saldo customer sudah terpakai, yang ditarik sebanyak yang ada; refund tidak pernah - diblokir karena ini. - -### 4.5 Earning di layar order dan struk - -Response order membawa `points_earned` dan `coins_earned` (0 bila order tidak -menghasilkan apa-apa). Earning dihitung dari `subtotal − discount − bagian yang -dibayar EnakPoint`, sebelum pajak, dan diberikan saat order lunas. - ---- - -## 5. Exchange EnakCoin → EnakPoint - -Kurs per organisasi: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1). - -1. **Preview** sebelum minta PIN: - - `GET /api/v1/customer/wallet/exchange/preview?coins=30` - - ```json - { "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true } - ``` - - Bila `valid: false`, tampilkan `reason` (misalnya harus kelipatan `coin_amount`, - atau EnakCoin tidak cukup). - -2. **Tukar:** - - `POST /api/v1/customer/wallet/exchange` dengan header **`Idempotency-Key`** (wajib, - maks. 50 karakter, satu key per percobaan tukar) - - ```json - { "coins": 30, "pin": "482913" } - ``` - - ```json - { - "group_id": "…", - "coins": 30, - "points": 9, - "coin_amount": 10, - "point_amount": 3, - "lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }], - "coin_balance": 5, - "point_balance": 9, - "replayed": false - } - ``` - -- Jumlah EnakCoin harus kelipatan `coin_amount`. Kesalahan jumlah ditolak **sebelum** - PIN dicek, jadi tidak memakan jatah percobaan PIN. -- Exchange tidak bisa dibatalkan; tampilkan konfirmasi. -- Kirim ulang dengan `Idempotency-Key` yang sama bila koneksi putus: hasil pertama - dikembalikan dengan `replayed: true` tanpa menukar lagi, dengan kurs saat itu. - `Idempotency-Key` yang sama untuk jumlah berbeda ditolak. -- EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (`lots` - menunjukkan tanggalnya). - ---- - -## 6. Transfer ke customer lain - -1. **Cek penerima** sebelum konfirmasi: - - `GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234` - - ```json - { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" } - ``` - - Nomor yang tidak terdaftar di organisasi yang sama dijawab `404`. Diri sendiri, - customer walk-in, atau customer nonaktif dijawab `304`. - -2. **Kirim:** - - `POST /api/v1/customer/wallet/transfer` dengan header **`Idempotency-Key`** (wajib) - - ```json - { "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" } - ``` - - ```json - { - "group_id": "…", - "currency": "POINT", - "amount": 120, - "recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }, - "lots": [ - { "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" }, - { "amount": 20, "expires_at": null } - ], - "balance": 30, - "replayed": false - } - ``` - -- `currency`: `POINT` atau `COIN`, satu jenis per transfer. -- Batas dari organisasi: transfer bisa dimatikan, ada minimal, maksimal per - transaksi, dan batas harian per currency (reset tengah malam WIB). Pelanggaran batas - ditolak `304` sebelum PIN dicek. -- Transfer final dan tidak bisa dibatalkan customer. -- Saldo yang dikirim membawa tanggal kedaluwarsa aslinya ke penerima (`lots`). - Tampilkan ini ke pengirim. -- Penerima mendapat push `WALLET_TRANSFER_IN` (§2.4). -- Retry dengan `Idempotency-Key` yang sama mengembalikan hasil pertama - (`replayed: true`) dan tidak dihitung dua kali terhadap batas harian. - ---- - -## 7. Game - -`POST /api/v1/customer/spin` dengan `{ "spin_id": "" }`. Tanpa PIN. - -Setiap game memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1). -Response: - -```json -{ - "game_play": { "id": "…", "game_id": "…", "coins_used": 1, "token_used": 1, "created_at": "…" }, - "prize_won": { "id": "…", "name": "Voucher 10rb", … }, - "coins_remaining": 7, - "tokens_remaining": 7 -} -``` - -EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada -EnakCoin yang terpotong. Baca `coins_used` dan `coins_remaining`; `token_used` dan -`tokens_remaining` hanya salinan untuk versi aplikasi lama. - -Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1. - ---- - -## 8. Endpoint lama (deprecated) - -Masih jalan dan membaca saldo wallet, tapi akan dihapus setelah semua versi aplikasi -pindah. Aplikasi baru jangan memakainya. - -| Lama | Ganti dengan | -|---|---| -| `GET /customer/points` | `GET /customer/wallet` (`point_balance`) | -| `GET /customer/tokens` | `GET /customer/wallet` (`coin_balance`) | -| `total_points`, `total_tokens`, `points_history`, `tokens_history`, `last_updated` di `/customer/wallet` | `point_balance`, `coin_balance`, `recent_transactions` | -| `token_used`, `tokens_remaining` di respons game | `coins_used`, `coins_remaining` | -| `sort_by=token_used` di daftar game play | `sort_by=coins_used` | - -Beri tahu tim backend setelah aplikasi yang beredar tidak lagi memakai kolom kiri, -supaya alias dan tabel lama (`customer_points`, `customer_tokens`) bisa dihapus. - ---- - -## 9. Dashboard - -Semua endpoint di bagian ini butuh login user dengan role Admin atau Manager. - -### 9.1 Pengaturan per outlet - -`GET` / `PUT /api/v1/outlets/:outlet_id/loyalty-settings` - -```json -{ - "point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null }, - "coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null }, - "point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 } -} -``` - -Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan -`point_value` organisasi dan `point_cashback_percent` -(`earn_value × point_value / earn_per_amount × 100`). **Tampilkan persentase ini di -samping setting** supaya owner tidak salah membaca skala: default di atas setara -cashback 1%. - -### 9.2 Pengaturan organisasi - -`GET` / `PUT /api/v1/marketing/loyalty-settings` (tambah `?dry_run=true` untuk preview -tanpa menyimpan) - -```json -{ - "point_value": 1, - "exchange": { "coin_amount": 1, "point_amount": 1 }, - "transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null }, - "point_expiry": { - "enabled": false, - "mode": "FIXED_DATE", - "fixed_dates": ["12-31"], - "grace_months": 3, - "period": 12, - "unit": "MONTH", - "end_of_month": false, - "reminder_days": 7 - }, - "coin_expiry": { … sama … } -} -``` - -Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan: - -- `impact`: total saldo beredar dan nilai rupiahnya **sebelum dan sesudah** perubahan - `point_value` atau kurs. Tampilkan sebagai peringatan sebelum owner menyimpan. -- `expiry_preview`: `{ "point": …, "coin": … }`, kapan saldo yang didapat hari ini - akan kedaluwarsa (`null` bila tidak kedaluwarsa). Tampilkan sebagai "EnakPoint yang - didapat hari ini kedaluwarsa pada 31 Des 2026". -- `expiry_activations`: bila perubahan ini **menyalakan** kedaluwarsa untuk pertama - kali, berapa saldo lama yang ikut diberi tanggal (`lots`, `amount`) dan tanggalnya - (`expires_at`). Selalu minta konfirmasi dengan `dry_run=true` dulu. -- `changes`: key yang berubah. - -**Kedaluwarsa** diatur per currency dengan salah satu model: - -| `mode` | Cara kerja | Field yang dipakai | -|---|---|---| -| `FIXED_DATE` (default) | Semua saldo hangus di tanggal tetap setiap tahun. Saldo yang didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya | `fixed_dates` (format `MM-DD`, boleh lebih dari satu, `02-29` ditolak), `grace_months` (0–24) | -| `ROLLING` | Tiap saldo berlaku sekian lama sejak didapat | `period`, `unit` (`DAY` / `MONTH`), `end_of_month` | - -- `reminder_days` berlaku untuk keduanya: customer diingatkan sekian hari sebelum - hangus (0 = tanpa pengingat). -- Mengubah pengaturan hanya berlaku untuk saldo yang masuk setelahnya. -- Menyalakan kedaluwarsa pertama kali memberi saldo lama masa berlaku penuh: tanggal - hangus kedua berikutnya (`FIXED_DATE`) atau satu periode penuh (`ROLLING`). -- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal. - -Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit=20` -(tambah `outlet_id=` untuk setting outlet). - -### 9.3 Wallet customer - -- `GET /api/v1/marketing/customers/:id/wallet` — saldo buku dan saldo yang bisa - dipakai, semua lot yang masih berisi, dan riwayat dengan nama asli (lawan transfer, - admin, kasir, outlet). Query riwayat sama seperti §2.2. -- `POST /api/v1/marketing/customers/:id/wallet/adjust` - - ```json - { "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" } - ``` - - `amount` bertanda. `reason` wajib. Pengurangan yang melebihi saldo ditolak. - Adjustment tidak disertai pembayaran uang, jadi jangan pakai alasan "pencairan". - -- `GET /api/v1/marketing/wallet-transactions/:id/trace` — telusuri satu mutasi per - butir: lot mana yang dipakai atau dibuat, lalu rantai asalnya lewat transfer, - exchange, atau refund sampai ke earning/adjustment/migrasi pertama. Contoh: dari - pembayaran B bisa terlihat bahwa EnakPoint-nya berasal dari order #ORD-1 milik A - yang mentransfer ke B. - -### 9.4 PIN customer - -- `DELETE /api/v1/marketing/customers/:id/pin` dengan `{ "reason": "…" }` — hapus PIN - bila customer kehilangan akses. Customer lalu membuat PIN baru lewat OTP. Admin - **tidak bisa** membuat, mengganti, atau melihat PIN. -- `GET /api/v1/marketing/customers/:id/security-events?page=1&limit=20` — log keamanan: - `PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`, - `PIN_REMOVED_BY_ADMIN`, beserta waktu, IP, dan perangkat. - ---- - -## 10. Checklist integrasi - -**Customer app** -- [ ] Daftarkan token FCM setelah login dan saat token berganti; hapus saat logout. -- [ ] Tangani empat kode error PIN (§3.4) di semua layar yang meminta PIN. -- [ ] Kirim `Idempotency-Key` baru untuk setiap exchange dan transfer, dan pakai ulang - key yang sama saat retry. -- [ ] Tampilkan nilai rupiah sebagai "setara potongan", bukan saldo uang. -- [ ] Baca `coins_used` / `coins_remaining` dan `/customer/wallet`, bukan field lama. - -**POS** -- [ ] Scan QR atau ketik kode bayar, jangan pernah meminta PIN customer di layar kasir. -- [ ] Pakai `point-payment/preview` untuk tombol "pakai maksimal". -- [ ] Cetak `points_used`, `points_earned`, dan `coins_earned` di struk. -- [ ] Refund bagian EnakPoint lewat pembayaran EnakPoint-nya, bukan tunai. - -**Dashboard** -- [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan - `expiry_activations` sebelum owner menyimpan setting. -- [ ] Isi `metadata.coin_cost` untuk setiap game. diff --git a/go.mod b/go.mod index f58d285..fa0aba6 100644 --- a/go.mod +++ b/go.mod @@ -76,7 +76,6 @@ require ( github.com/subosito/gotenv v1.4.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect - github.com/yuin/gopher-lua v1.1.1 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect @@ -108,7 +107,6 @@ require ( require ( firebase.google.com/go/v4 v4.19.0 - github.com/alicebob/miniredis/v2 v2.39.0 github.com/aws/aws-sdk-go v1.55.7 github.com/boombuler/barcode v1.1.0 github.com/golang-jwt/jwt/v5 v5.2.3 diff --git a/go.sum b/go.sum index b178a33..3f6c466 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,6 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= -github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g= -github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= @@ -351,8 +349,6 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= -github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= diff --git a/internal/app/app.go b/internal/app/app.go index 0e04b7e..ace698f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -31,9 +31,6 @@ type App struct { router *router.Router shutdown chan os.Signal omsetScheduler *service.OmsetMilestoneScheduler - walletRecon *service.WalletReconciliationJob - earningRetry *service.EarningBackfillJob - walletExpiry *service.WalletExpiryJob } func NewApp(db *gorm.DB, redisClient *redis.Client) *App { @@ -56,18 +53,6 @@ func (a *App) Initialize(cfg *config.Config) error { processors.notificationProcessor, ) - // Checks that wallet balances, ledger and lots agree (docs/prd-point-coin.md §7.5) - a.walletRecon = service.NewWalletReconciliationJob( - repository.NewWalletReconciliationRepository(a.db), - repos.userRepo, - processors.notificationProcessor, - ) - // Earns for paid orders whose earning failed at payment time (docs/prd-point-coin.md F3) - a.earningRetry = service.NewEarningBackfillJob(processors.earningProcessor) - // Expires balances whose time is up and reminds customers before (docs/prd-point-coin.md F12) - a.walletExpiry = service.NewWalletExpiryJob(processor.NewWalletExpiryProcessor( - repository.NewWalletExpiryRepository(a.db), processors.loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, processors.customerDeviceProcessor)) - services := a.initServices(processors, repos, cfg) validators := a.initValidators() middleware := a.initMiddleware(services, cfg) @@ -157,14 +142,6 @@ func (a *App) Initialize(cfg *config.Config) error { validators.expenseValidator, services.cashAdvanceService, validators.cashAdvanceValidator, - services.walletAdminService, - validators.walletValidator, - services.loyaltySettingsService, - services.customerPinService, - services.pointPaymentService, - services.customerOrderPaymentService, - services.customerWalletService, - services.customerDeviceService, a.redisClient, ) @@ -176,15 +153,6 @@ func (a *App) Start(port string) error { if a.omsetScheduler != nil { a.omsetScheduler.Start(5 * time.Minute) } - if a.walletRecon != nil { - a.walletRecon.Start(6 * time.Hour) - } - if a.earningRetry != nil { - a.earningRetry.Start(30 * time.Minute) - } - if a.walletExpiry != nil { - a.walletExpiry.Start(15 * time.Minute) - } engine := a.router.Init() @@ -224,15 +192,6 @@ func (a *App) Shutdown() { if a.omsetScheduler != nil { a.omsetScheduler.Stop() } - if a.walletRecon != nil { - a.walletRecon.Stop() - } - if a.earningRetry != nil { - a.earningRetry.Stop() - } - if a.walletExpiry != nil { - a.walletExpiry.Stop() - } close(a.shutdown) } @@ -267,6 +226,7 @@ type repositories struct { chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl accountRepo *repository.AccountRepositoryImpl orderIngredientTransactionRepo *repository.OrderIngredientTransactionRepositoryImpl + customerTokensRepo *repository.CustomerTokensRepository tierRepo *repository.TierRepository gameRepo *repository.GameRepository gamePrizeRepo *repository.GamePrizeRepository @@ -276,6 +236,7 @@ type repositories struct { campaignRepo repository.CampaignRepository campaignRuleRepo repository.CampaignRuleRepository customerAuthRepo repository.CustomerAuthRepository + customerPointsRepo repository.CustomerPointsRepository otpRepo repository.OtpRepository sessionRepo repository.SessionRepository txManager *repository.TxManager @@ -286,9 +247,6 @@ type repositories struct { productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl expenseRepo *repository.ExpenseRepositoryImpl cashAdvanceRepo *repository.CashAdvanceRepositoryImpl - walletRepo repository.WalletRepository - walletQueryRepo repository.WalletQueryRepository - loyaltySettingsRepo repository.LoyaltySettingsRepository } func (a *App) initRepositories() *repositories { @@ -323,6 +281,7 @@ func (a *App) initRepositories() *repositories { chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db), accountRepo: repository.NewAccountRepositoryImpl(a.db), orderIngredientTransactionRepo: repository.NewOrderIngredientTransactionRepositoryImpl(a.db).(*repository.OrderIngredientTransactionRepositoryImpl), + customerTokensRepo: repository.NewCustomerTokensRepository(a.db), tierRepo: repository.NewTierRepository(a.db), gameRepo: repository.NewGameRepository(a.db), gamePrizeRepo: repository.NewGamePrizeRepository(a.db), @@ -332,6 +291,7 @@ func (a *App) initRepositories() *repositories { campaignRepo: repository.NewCampaignRepository(a.db), campaignRuleRepo: repository.NewCampaignRuleRepository(a.db), customerAuthRepo: repository.NewCustomerAuthRepository(a.db), + customerPointsRepo: repository.NewCustomerPointsRepository(a.db), otpRepo: repository.NewOtpRepository(a.db), sessionRepo: repository.NewSessionRepository(a.redisClient), txManager: repository.NewTxManager(a.db), @@ -342,9 +302,6 @@ func (a *App) initRepositories() *repositories { productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db), expenseRepo: repository.NewExpenseRepositoryImpl(a.db), cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db), - walletRepo: repository.NewWalletRepository(a.db), - walletQueryRepo: repository.NewWalletQueryRepository(a.db), - loyaltySettingsRepo: repository.NewLoyaltySettingsRepository(a.db), } } @@ -374,6 +331,7 @@ type processors struct { chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl accountProcessor *processor.AccountProcessorImpl orderIngredientTransactionProcessor *processor.OrderIngredientTransactionProcessorImpl + customerTokensProcessor *processor.CustomerTokensProcessor tierProcessor *processor.TierProcessor gameProcessor *processor.GameProcessor gamePrizeProcessor *processor.GamePrizeProcessor @@ -392,44 +350,14 @@ type processors struct { productOutletPriceProcessor processor.ProductOutletPriceProcessor expenseProcessor *processor.ExpenseProcessorImpl cashAdvanceProcessor *processor.CashAdvanceProcessorImpl - walletProcessor *processor.WalletProcessor - walletAdminProcessor *processor.WalletAdminProcessor - loyaltySettingsProcessor *processor.LoyaltySettingsProcessor - earningProcessor *processor.EarningProcessor - customerPinProcessor *processor.CustomerPinProcessor - paymentCodeProcessor *processor.PaymentCodeProcessor - pointPaymentProcessor *processor.PointPaymentProcessor - walletExchangeProcessor *processor.WalletExchangeProcessor - walletTransferProcessor *processor.WalletTransferProcessor - walletTraceProcessor *processor.WalletTraceProcessor - customerDeviceProcessor *processor.CustomerDeviceProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { fileClient := client.NewFileClient(cfg.S3Config) - fcmClient := buildFCMClient(cfg) - // Devices of the customer app, for pushes through FCM - customerDeviceProcessor := processor.NewCustomerDeviceProcessor(repository.NewCustomerDeviceRepository(a.db), fcmClient) fonnteClient := client.NewFonnteClient(cfg.GetFonnte()) otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo) - // Customer PIN (docs/prd-point-coin.md F11) - customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, customerDeviceProcessor) - paymentCodeProcessor := processor.NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(a.redisClient), customerPinProcessor) inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo) - orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo) - loyaltySettingsProcessor := processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager) - // Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3) - earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) - orderProcessor.SetLoyalty(earningProcessor) - // Pay orders with EnakPoint, approved by the customer's one-time code (docs/prd-point-coin.md F9) - pointPaymentProcessor := processor.NewPointPaymentProcessor(repository.NewPointPaymentRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) - orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor) - // Exchange EnakCoin into EnakPoint, approved by the customer's PIN (docs/prd-point-coin.md F4) - walletExchangeProcessor := processor.NewWalletExchangeProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) - // Send EnakPoint or EnakCoin to another customer; the recipient gets a push through FCM (docs/prd-point-coin.md F5) - walletTransferProcessor := processor.NewWalletTransferProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, customerDeviceProcessor) - return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), organizationProcessor: processor.NewOrganizationProcessorImpl(repos.organizationRepo, repos.outletRepo, repos.userRepo), @@ -439,8 +367,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo, repos.productOutletPriceRepo), productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo), inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo), - orderProcessor: orderProcessor, - paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo, loyaltySettingsProcessor), + orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo), + paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo), fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient), customerProcessor: processor.NewCustomerProcessor(repos.customerRepo), analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo), @@ -456,35 +384,25 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo), accountProcessor: processor.NewAccountProcessorImpl(repos.accountRepo, repos.chartOfAccountRepo), orderIngredientTransactionProcessor: processor.NewOrderIngredientTransactionProcessorImpl(repos.orderIngredientTransactionRepo, repos.productRecipeRepo, repos.ingredientRepo, repos.unitRepo).(*processor.OrderIngredientTransactionProcessorImpl), + customerTokensProcessor: processor.NewCustomerTokensProcessor(repos.customerTokensRepo), tierProcessor: processor.NewTierProcessor(repos.tierRepo), gameProcessor: processor.NewGameProcessor(repos.gameRepo), gamePrizeProcessor: processor.NewGamePrizeProcessor(repos.gamePrizeRepo), - gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, processor.NewWalletProcessor(repos.walletRepo), repos.walletQueryRepo, repos.txManager), + gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, repos.customerTokensRepo, repos.customerPointsRepo), omsetTrackerProcessor: processor.NewOmsetTrackerProcessor(repos.omsetTrackerRepo), rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo), campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo), customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()), - customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)), repos.gameRepo), + customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo, repos.gameRepo), otpProcessor: otpProcessor, fileClient: fileClient, inventoryMovementService: inventoryMovementService, userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo), - notificationProcessor: buildNotificationProcessor(repos, fcmClient), + notificationProcessor: buildNotificationProcessor(cfg, repos), productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo), expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo), cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo), - walletProcessor: processor.NewWalletProcessor(repos.walletRepo), - loyaltySettingsProcessor: loyaltySettingsProcessor, - earningProcessor: earningProcessor, - customerPinProcessor: customerPinProcessor, - paymentCodeProcessor: paymentCodeProcessor, - pointPaymentProcessor: pointPaymentProcessor, - walletExchangeProcessor: walletExchangeProcessor, - walletTransferProcessor: walletTransferProcessor, - walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)), - customerDeviceProcessor: customerDeviceProcessor, - walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), loyaltySettingsProcessor, repos.txManager), } } @@ -527,13 +445,6 @@ type services struct { productOutletPriceService service.ProductOutletPriceService expenseService *service.ExpenseServiceImpl cashAdvanceService *service.CashAdvanceServiceImpl - walletAdminService *service.WalletAdminServiceImpl - loyaltySettingsService *service.LoyaltySettingsServiceImpl - customerPinService *service.CustomerPinServiceImpl - pointPaymentService *service.PointPaymentServiceImpl - customerOrderPaymentService *service.CustomerOrderPaymentServiceImpl - customerWalletService *service.CustomerWalletServiceImpl - customerDeviceService *service.CustomerDeviceServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -564,12 +475,12 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor) accountService := service.NewAccountService(processors.accountProcessor) orderIngredientTransactionService := service.NewOrderIngredientTransactionService(processors.orderIngredientTransactionProcessor, repos.txManager) - gamificationService := service.NewGamificationService(processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor) + gamificationService := service.NewGamificationService(processors.customerPointsProcessor, processors.customerTokensProcessor, processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor) rewardService := service.NewRewardService(processors.rewardProcessor) campaignService := service.NewCampaignService(processors.campaignProcessor, processors.campaignRuleProcessor) customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor) customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor) - spinGameService := service.NewSpinGameService(processors.gamePlayProcessor) + spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager) userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor) notificationService := service.NewNotificationService(processors.notificationProcessor) @@ -615,13 +526,6 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor), expenseService: service.NewExpenseService(processors.expenseProcessor), cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor), - walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor, processors.walletTraceProcessor), - loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo), - customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor), - pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor), - customerOrderPaymentService: service.NewCustomerOrderPaymentService(processors.orderProcessor), - customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor, processors.walletTransferProcessor), - customerDeviceService: service.NewCustomerDeviceService(processors.customerDeviceProcessor), } } @@ -667,7 +571,6 @@ type validators struct { productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl expenseValidator *validator.ExpenseValidatorImpl cashAdvanceValidator *validator.CashAdvanceValidatorImpl - walletValidator *validator.WalletValidatorImpl } func (a *App) initValidators() *validators { @@ -701,27 +604,22 @@ func (a *App) initValidators() *validators { productOutletPriceValidator: validator.NewProductOutletPriceValidator(), expenseValidator: validator.NewExpenseValidator(), cashAdvanceValidator: validator.NewCashAdvanceValidator(), - walletValidator: validator.NewWalletValidator(), } } -// buildFCMClient connects to FCM, or returns nil when it is not configured or fails to -// start. FCM failing is non-fatal: pushes are skipped and notifications are still -// persisted. -func buildFCMClient(cfg *config.Config) client.FCMClient { - if cfg.FCM.CredentialsFile == "" { - return nil - } - fcmClient, err := client.NewFCMClient(&cfg.FCM) - if err != nil { - return nil - } - return fcmClient -} - // buildNotificationProcessor creates the notification processor with FCM integration. // If FCM is not configured, it returns a processor with a nil FCM client (FCM dispatch will be skipped). -func buildNotificationProcessor(repos *repositories, fcmClient client.FCMClient) *processor.NotificationProcessorImpl { +func buildNotificationProcessor(cfg *config.Config, repos *repositories) *processor.NotificationProcessorImpl { + var fcmClient client.FCMClient + if cfg.FCM.CredentialsFile != "" { + var err error + fcmClient, err = client.NewFCMClient(&cfg.FCM) + if err != nil { + // FCM init failure is non-fatal; notifications will still be persisted. + fcmClient = nil + } + } + return processor.NewNotificationProcessor( repos.notificationRepo, repos.notificationReceiverRepo, diff --git a/internal/constants/error.go b/internal/constants/error.go index d0967fc..c2cd49b 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -12,12 +12,6 @@ const ( ValidationErrorCode = "304" InvalidFieldErrorCode = "305" NotFoundErrorCode = "404" - // PIN outcomes the customer app tells apart (docs/prd-point-coin.md §9). - PinNotSetErrorCode = "PIN_NOT_SET" - PinInvalidErrorCode = "PIN_INVALID" - PinLockedErrorCode = "PIN_LOCKED" - TransferBlockedErrorCode = "TRANSFER_BLOCKED" - TooManyRequestsErrorCode = "429" ) const ( @@ -69,23 +63,15 @@ const ( ProductOutletPriceServiceEntity = "product_outlet_price_service" ExpenseServiceEntity = "expense_service" CashAdvanceServiceEntity = "cash_advance_service" - WalletServiceEntity = "wallet_service" - LoyaltySettingsServiceEntity = "loyalty_settings_service" - CustomerPinServiceEntity = "customer_pin_service" ) var HttpErrorMap = map[string]int{ - InternalServerErrorCode: http.StatusInternalServerError, - MissingFieldErrorCode: http.StatusBadRequest, - MalformedFieldErrorCode: http.StatusBadRequest, - ValidationErrorCode: http.StatusBadRequest, - InvalidFieldErrorCode: http.StatusBadRequest, - NotFoundErrorCode: http.StatusNotFound, - PinNotSetErrorCode: http.StatusForbidden, - PinInvalidErrorCode: http.StatusBadRequest, - PinLockedErrorCode: http.StatusLocked, - TransferBlockedErrorCode: http.StatusForbidden, - TooManyRequestsErrorCode: http.StatusTooManyRequests, + InternalServerErrorCode: http.StatusInternalServerError, + MissingFieldErrorCode: http.StatusBadRequest, + MalformedFieldErrorCode: http.StatusBadRequest, + ValidationErrorCode: http.StatusBadRequest, + InvalidFieldErrorCode: http.StatusBadRequest, + NotFoundErrorCode: http.StatusNotFound, } // Error messages diff --git a/internal/constants/loyalty.go b/internal/constants/loyalty.go deleted file mode 100644 index 6721edf..0000000 --- a/internal/constants/loyalty.go +++ /dev/null @@ -1,86 +0,0 @@ -package constants - -// Loyalty setting keys (docs/prd-point-coin.md F1, F2, F12). Outlet keys live in -// outlet_settings and organization keys in organization_settings. A key that was -// never set takes the default in the PRD. - -// Per outlet (F1): what an order earns, and whether EnakPoint can pay. -const ( - LoyaltyPointEnabledKey = "loyalty.point.enabled" - LoyaltyPointEarnPerAmountKey = "loyalty.point.earn_per_amount" - LoyaltyPointEarnValueKey = "loyalty.point.earn_value" - LoyaltyPointMinOrderAmountKey = "loyalty.point.min_order_amount" - LoyaltyPointMaxPerOrderKey = "loyalty.point.max_per_order" - - LoyaltyCoinEnabledKey = "loyalty.coin.enabled" - LoyaltyCoinEarnPerAmountKey = "loyalty.coin.earn_per_amount" - LoyaltyCoinEarnValueKey = "loyalty.coin.earn_value" - LoyaltyCoinMinOrderAmountKey = "loyalty.coin.min_order_amount" - LoyaltyCoinMaxPerOrderKey = "loyalty.coin.max_per_order" - - LoyaltyPointAcceptPaymentKey = "loyalty.point.accept_payment" - LoyaltyPointMinPaymentPointsKey = "loyalty.point.min_payment_points" - LoyaltyPointMaxPaymentPercentKey = "loyalty.point.max_payment_percent" -) - -// Per organization (F2, F12): the value of EnakPoint, the exchange rate, transfers and -// expiry, which must be the same in every outlet. -const ( - LoyaltyPointValueKey = "loyalty.point.value" - - LoyaltyExchangeCoinAmountKey = "loyalty.exchange.coin_amount" - LoyaltyExchangePointAmountKey = "loyalty.exchange.point_amount" - - LoyaltyTransferEnabledKey = "loyalty.transfer.enabled" - LoyaltyTransferMinAmountKey = "loyalty.transfer.min_amount" - LoyaltyTransferMaxPerTransactionKey = "loyalty.transfer.max_per_transaction" - LoyaltyTransferDailyLimitKey = "loyalty.transfer.daily_limit" - - // Suffixes after "loyalty.point." or "loyalty.coin.". - LoyaltyExpiryEnabledSuffix = "expiry_enabled" - LoyaltyExpiryPeriodSuffix = "expiry_period" - LoyaltyExpiryUnitSuffix = "expiry_unit" - LoyaltyExpiryEndOfMonthSuffix = "expiry_end_of_month" - LoyaltyExpiryReminderDaysSuffix = "expiry_reminder_days" - LoyaltyExpiryModeSuffix = "expiry_mode" - LoyaltyExpiryFixedDatesSuffix = "expiry_fixed_dates" - LoyaltyExpiryGraceMonthsSuffix = "expiry_grace_months" -) - -// Units of loyalty.{point,coin}.expiry_unit. -const ( - LoyaltyExpiryUnitDay = "DAY" - LoyaltyExpiryUnitMonth = "MONTH" -) - -// Modes of loyalty.{point,coin}.expiry_mode (note N4, decided: both are supported). -const ( - // Every balance expires on the same fixed dates of the year, such as 31 Dec. - LoyaltyExpiryModeFixedDate = "FIXED_DATE" - // Every balance lasts a period from when it was received. - LoyaltyExpiryModeRolling = "ROLLING" -) - -// Defaults from the PRD. -const ( - LoyaltyPointEarnPerAmountDefault = int64(100) - LoyaltyCoinEarnPerAmountDefault = int64(25000) - LoyaltyEarnValueDefault = int64(1) - - LoyaltyMinPaymentPointsDefault = int64(1) - LoyaltyMaxPaymentPercentDefault = int64(100) - - LoyaltyPointValueDefault = int64(1) - LoyaltyExchangeAmountDefault = int64(1) - - LoyaltyTransferEnabledDefault = true - LoyaltyTransferMinAmountDefault = int64(1) - - LoyaltyExpiryPeriodDefault = int64(12) - LoyaltyExpiryUnitDefault = LoyaltyExpiryUnitMonth - LoyaltyExpiryReminderDaysDefault = int64(7) - LoyaltyExpiryModeDefault = LoyaltyExpiryModeFixedDate - // Once a year on 31 December, as MM-DD. - LoyaltyExpiryFixedDatesDefault = "12-31" - LoyaltyExpiryGraceMonthsDefault = int64(3) -) diff --git a/internal/constants/payment.go b/internal/constants/payment.go index 75138db..a95330e 100644 --- a/internal/constants/payment.go +++ b/internal/constants/payment.go @@ -8,9 +8,6 @@ const ( PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet" PaymentMethodTypeQR PaymentMethodType = "qr" PaymentMethodTypeEDC PaymentMethodType = "edc" - // Paying with EnakPoint (docs/prd-point-coin.md F9). Not accepted as a payment method - // type until that phase ships. - PaymentMethodTypePoint PaymentMethodType = "point" ) type PaymentStatus string diff --git a/internal/constants/wallet.go b/internal/constants/wallet.go deleted file mode 100644 index 2892fdd..0000000 --- a/internal/constants/wallet.go +++ /dev/null @@ -1,44 +0,0 @@ -package constants - -// The two balances a customer wallet holds (docs/prd-point-coin.md). EnakPoint pays -// for orders; EnakCoin is spent on games and can be exchanged into EnakPoint. -const ( - WalletCurrencyPoint = "POINT" - WalletCurrencyCoin = "COIN" -) - -func IsValidWalletCurrency(currency string) bool { - return currency == WalletCurrencyPoint || currency == WalletCurrencyCoin -} - -// Ledger row types. §8.1 of the PRD lists, per type, which currency it may use, which -// way it moves the balance, and which reference it must carry. -const ( - WalletTxTypeEarn = "EARN" - WalletTxTypeEarnReversal = "EARN_REVERSAL" - WalletTxTypePayment = "PAYMENT" - WalletTxTypePaymentRefund = "PAYMENT_REFUND" - WalletTxTypeExchangeOut = "EXCHANGE_OUT" - WalletTxTypeExchangeIn = "EXCHANGE_IN" - WalletTxTypeTransferOut = "TRANSFER_OUT" - WalletTxTypeTransferIn = "TRANSFER_IN" - WalletTxTypeGameSpend = "GAME_SPEND" - WalletTxTypeExpire = "EXPIRE" - WalletTxTypeAdjustment = "ADJUSTMENT" - WalletTxTypeMigration = "MIGRATION" - WalletTxTypeRewardRedeem = "REWARD_REDEEM" -) - -// What a ledger row's reference_id points at: where the value came from for a -// credit, or where it went for a debit. -const ( - WalletRefTypeOrder = "ORDER" - WalletRefTypePayment = "PAYMENT" - WalletRefTypeWalletTx = "WALLET_TX" - WalletRefTypeGamePlay = "GAME_PLAY" - WalletRefTypeLot = "LOT" - WalletRefTypeUser = "USER" - WalletRefTypeLegacyPoints = "LEGACY_POINTS" - WalletRefTypeLegacyTokens = "LEGACY_TOKENS" - WalletRefTypeRewardRedemption = "REWARD_REDEMPTION" -) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 3b48206..1d2045f 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -28,11 +28,7 @@ type PaymentMethodAnalyticsResponse struct { // PaymentMethodSummary represents the summary of payment method analytics type PaymentMethodSummary struct { - // Money actually received; EnakPoint is reported apart (docs/prd-point-coin.md F9). TotalAmount float64 `json:"total_amount"` - PointAmount float64 `json:"point_amount"` - PointsUsed int64 `json:"points_used"` - TotalWithPoints float64 `json:"total_with_points"` TotalOrders int64 `json:"total_orders"` TotalPayments int64 `json:"total_payments"` AverageOrderValue float64 `json:"average_order_value"` @@ -46,8 +42,6 @@ type PaymentMethodAnalyticsData struct { OrderCount int64 `json:"order_count"` PaymentCount int64 `json:"payment_count"` Percentage float64 `json:"percentage"` - PointsUsed int64 `json:"points_used"` - CountsAsCashIn bool `json:"counts_as_cash_in"` } type SalesAnalyticsRequest struct { diff --git a/internal/contract/campaign_contract.go b/internal/contract/campaign_contract.go index f19d4a2..bb5e479 100644 --- a/internal/contract/campaign_contract.go +++ b/internal/contract/campaign_contract.go @@ -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 COINS TOKENS MIXED"` + Type string `json:"type" binding:"required,oneof=REWARD POINTS 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 COINS TOKENS MIXED"` + Type string `json:"type" binding:"required,oneof=REWARD POINTS 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 COINS TOKENS REWARD"` + RewardType string `json:"reward_type" binding:"required,oneof=POINTS 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 COINS TOKENS REWARD"` + RewardType string `json:"reward_type" binding:"required,oneof=POINTS 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 COINS TOKENS REWARD"` + RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"` RewardValue *int64 `json:"reward_value,omitempty"` RewardSubtype *string `json:"reward_subtype,omitempty"` RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` diff --git a/internal/contract/customer_device_contract.go b/internal/contract/customer_device_contract.go deleted file mode 100644 index f304af0..0000000 --- a/internal/contract/customer_device_contract.go +++ /dev/null @@ -1,11 +0,0 @@ -package contract - -// RegisterCustomerDeviceRequest is PUT /customer/devices: the FCM token the customer -// app got for this device. Send it after login and whenever FCM refreshes the token. -type RegisterCustomerDeviceRequest struct { - DeviceID string `json:"device_id" binding:"required"` - FCMToken string `json:"fcm_token" binding:"required"` - // android, ios or web. - Platform *string `json:"platform"` - AppVersion *string `json:"app_version"` -} diff --git a/internal/contract/customer_pin_contract.go b/internal/contract/customer_pin_contract.go deleted file mode 100644 index cb550cc..0000000 --- a/internal/contract/customer_pin_contract.go +++ /dev/null @@ -1,39 +0,0 @@ -package contract - -// Requests of /customer/pin and /marketing/customers/:id/pin (docs/prd-point-coin.md -// F11). PINs are strings so a leading zero is kept. - -type RequestPinOtpRequest struct { - // pin_setup or pin_reset. - Purpose string `json:"purpose" binding:"required"` -} - -type CreateCustomerPinRequest struct { - OtpToken string `json:"otp_token" binding:"required"` - OtpCode string `json:"otp_code" binding:"required"` - Pin string `json:"pin" binding:"required"` - ConfirmPin string `json:"confirm_pin" binding:"required"` -} - -type ChangeCustomerPinRequest struct { - OldPin string `json:"old_pin" binding:"required"` - Pin string `json:"pin" binding:"required"` - ConfirmPin string `json:"confirm_pin" binding:"required"` -} - -type ResetCustomerPinRequest = CreateCustomerPinRequest - -type RemoveCustomerPinRequest struct { - Reason string `json:"reason" binding:"required"` -} - -// IssuePaymentCodeRequest is POST /customer/wallet/payment-code. -type IssuePaymentCodeRequest struct { - Pin string `json:"pin" binding:"required"` -} - -// PayWithPointsRequest is POST /customer/orders/:id/pay-with-points. -type PayWithPointsRequest struct { - Points int64 `json:"points" binding:"required,min=1"` - Pin string `json:"pin" binding:"required"` -} diff --git a/internal/contract/customer_points_contract.go b/internal/contract/customer_points_contract.go index 591758c..d7cd65a 100644 --- a/internal/contract/customer_points_contract.go +++ b/internal/contract/customer_points_contract.go @@ -2,8 +2,66 @@ package contract import ( "time" + + "github.com/google/uuid" ) +// Existing gamification contracts +type CreateCustomerPointsRequest struct { + CustomerID uuid.UUID `json:"customer_id" validate:"required"` + Balance int64 `json:"balance" validate:"min=0"` +} + +type UpdateCustomerPointsRequest struct { + Balance int64 `json:"balance" validate:"min=0"` +} + +type AddCustomerPointsRequest struct { + Points int64 `json:"points" validate:"required,min=1"` +} + +type DeductCustomerPointsRequest struct { + Points int64 `json:"points" validate:"required,min=1"` +} + +type CustomerPointsResponse struct { + ID uuid.UUID `json:"id"` + CustomerID uuid.UUID `json:"customer_id"` + Balance int64 `json:"balance"` + Customer *CustomerResponse `json:"customer,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListCustomerPointsRequest struct { + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Search string `json:"search"` + SortBy string `json:"sort_by" validate:"omitempty,oneof=balance created_at updated_at"` + SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"` +} + +type PaginatedCustomerPointsResponse struct { + Data []CustomerPointsResponse `json:"data"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} + +// New customer API contracts +type GetCustomerPointsRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + +type GetCustomerTokensRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + +type GetCustomerWalletRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + // Response Contracts type GetCustomerPointsResponse struct { Status string `json:"status"` diff --git a/internal/contract/customer_tokens_contract.go b/internal/contract/customer_tokens_contract.go new file mode 100644 index 0000000..21d90c6 --- /dev/null +++ b/internal/contract/customer_tokens_contract.go @@ -0,0 +1,52 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +type CreateCustomerTokensRequest struct { + CustomerID uuid.UUID `json:"customer_id" validate:"required"` + TokenType string `json:"token_type" validate:"required,oneof=SPIN RAFFLE MINIGAME"` + Balance int64 `json:"balance" validate:"min=0"` +} + +type UpdateCustomerTokensRequest struct { + Balance int64 `json:"balance" validate:"min=0"` +} + +type AddCustomerTokensRequest struct { + Tokens int64 `json:"tokens" validate:"required,min=1"` +} + +type DeductCustomerTokensRequest struct { + Tokens int64 `json:"tokens" validate:"required,min=1"` +} + +type CustomerTokensResponse struct { + ID uuid.UUID `json:"id"` + CustomerID uuid.UUID `json:"customer_id"` + TokenType string `json:"token_type"` + Balance int64 `json:"balance"` + Customer *CustomerResponse `json:"customer,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListCustomerTokensRequest struct { + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Search string `json:"search"` + TokenType string `json:"token_type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"` + SortBy string `json:"sort_by" validate:"omitempty,oneof=balance token_type created_at updated_at"` + SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"` +} + +type PaginatedCustomerTokensResponse struct { + Data []CustomerTokensResponse `json:"data"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} diff --git a/internal/contract/game_play_contract.go b/internal/contract/game_play_contract.go index b32ad4f..57ab50d 100644 --- a/internal/contract/game_play_contract.go +++ b/internal/contract/game_play_contract.go @@ -9,17 +9,15 @@ import ( type CreateGamePlayRequest struct { GameID uuid.UUID `json:"game_id" validate:"required"` CustomerID uuid.UUID `json:"customer_id" validate:"required"` - CoinsUsed int `json:"coins_used" validate:"min=0"` + TokenUsed int `json:"token_used" validate:"min=0"` RandomSeed *string `json:"random_seed,omitempty"` } type GamePlayResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - CustomerID uuid.UUID `json:"customer_id"` - PrizeID *uuid.UUID `json:"prize_id,omitempty"` - CoinsUsed int `json:"coins_used"` - // Deprecated: the same as CoinsUsed, kept until the apps read coins_used. + ID uuid.UUID `json:"id"` + GameID uuid.UUID `json:"game_id"` + CustomerID uuid.UUID `json:"customer_id"` + PrizeID *uuid.UUID `json:"prize_id,omitempty"` TokenUsed int `json:"token_used"` RandomSeed *string `json:"random_seed,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -35,7 +33,7 @@ type ListGamePlaysRequest struct { GameID *uuid.UUID `json:"game_id"` CustomerID *uuid.UUID `json:"customer_id"` PrizeID *uuid.UUID `json:"prize_id"` - SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"` + SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at token_used"` SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"` } @@ -47,17 +45,14 @@ type PaginatedGamePlaysResponse struct { TotalPages int `json:"total_pages"` } -// PlayGameRequest plays a game for a customer. What it costs is the game's -// metadata.coin_cost (docs/prd-point-coin.md F8), never the caller's choice. type PlayGameRequest struct { GameID uuid.UUID `json:"game_id" validate:"required"` CustomerID uuid.UUID `json:"customer_id" validate:"required"` + TokenUsed int `json:"token_used" validate:"min=0"` } type PlayGameResponse struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` - CoinsRemaining int64 `json:"coins_remaining"` - // Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining. - TokensRemaining int64 `json:"tokens_remaining"` + GamePlay GamePlayResponse `json:"game_play"` + PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` + TokensRemaining int64 `json:"tokens_remaining"` } diff --git a/internal/contract/order_contract.go b/internal/contract/order_contract.go index 4ce5f20..80ea44f 100644 --- a/internal/contract/order_contract.go +++ b/internal/contract/order_contract.go @@ -92,10 +92,6 @@ type OrderResponse struct { TotalPaid float64 `json:"total_paid"` PaymentCount int `json:"payment_count"` SplitType *string `json:"split_type,omitempty"` - // EnakPoint and EnakCoin the order earned, for the order screen and the receipt - // (docs/prd-point-coin.md F3). Zero when it earned nothing. - PointsEarned int64 `json:"points_earned"` - CoinsEarned int64 `json:"coins_earned"` } type OrderItemResponse struct { @@ -186,13 +182,9 @@ type SetOrderCustomerResponse struct { } type CreatePaymentRequest struct { - OrderID uuid.UUID `json:"order_id" validate:"required"` - PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"` - // For the EnakPoint method: how many to use and the code the customer shows. The - // amount is then computed from them. - Points *int64 `json:"points,omitempty"` - PaymentCode *string `json:"payment_code,omitempty"` - Amount float64 `json:"amount" validate:"min=0"` + OrderID uuid.UUID `json:"order_id" validate:"required"` + PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"` + Amount float64 `json:"amount" validate:"required,min=0"` TransactionID *string `json:"transaction_id,omitempty" validate:"omitempty"` SplitNumber int `json:"split_number,omitempty" validate:"omitempty,min=1"` SplitTotal int `json:"split_total,omitempty" validate:"omitempty,min=1"` @@ -208,23 +200,19 @@ type CreatePaymentOrderItemRequest struct { } type PaymentResponse struct { - ID uuid.UUID `json:"id"` - OrderID uuid.UUID `json:"order_id"` - PaymentMethodID uuid.UUID `json:"payment_method_id"` - PaymentMethodName string `json:"payment_method_name"` - PaymentMethodType string `json:"payment_method_type"` - Amount float64 `json:"amount"` - Status string `json:"status"` - TransactionID *string `json:"transaction_id,omitempty"` - SplitNumber int `json:"split_number"` - SplitTotal int `json:"split_total"` - SplitType *string `json:"split_type,omitempty"` - SplitDescription *string `json:"split_description,omitempty"` - RefundAmount float64 `json:"refund_amount"` - // Set for a payment with EnakPoint, for "EnakPoint: 50.000 (Rp 50.000)" on the - // receipt (docs/prd-point-coin.md F9). - PointsUsed *int64 `json:"points_used,omitempty"` - PointValue *float64 `json:"point_value,omitempty"` + ID uuid.UUID `json:"id"` + OrderID uuid.UUID `json:"order_id"` + PaymentMethodID uuid.UUID `json:"payment_method_id"` + PaymentMethodName string `json:"payment_method_name"` + PaymentMethodType string `json:"payment_method_type"` + Amount float64 `json:"amount"` + Status string `json:"status"` + TransactionID *string `json:"transaction_id,omitempty"` + SplitNumber int `json:"split_number"` + SplitTotal int `json:"split_total"` + SplitType *string `json:"split_type,omitempty"` + SplitDescription *string `json:"split_description,omitempty"` + RefundAmount float64 `json:"refund_amount"` RefundReason *string `json:"refund_reason,omitempty"` RefundedAt *time.Time `json:"refunded_at,omitempty"` RefundedBy *uuid.UUID `json:"refunded_by,omitempty"` diff --git a/internal/contract/payment_method_contract.go b/internal/contract/payment_method_contract.go index e7e2155..9153b7a 100644 --- a/internal/contract/payment_method_contract.go +++ b/internal/contract/payment_method_contract.go @@ -18,7 +18,7 @@ type CreatePaymentMethodRequest struct { type UpdatePaymentMethodRequest struct { Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"` - Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"` + Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"` Processor *string `json:"processor,omitempty" validate:"omitempty,max=100"` Configuration map[string]interface{} `json:"configuration,omitempty"` IsActive *bool `json:"is_active,omitempty"` @@ -38,13 +38,11 @@ type PaymentMethodResponse struct { type ListPaymentMethodsRequest struct { OrganizationID *uuid.UUID `json:"organization_id,omitempty"` - // When set, EnakPoint is left out unless the outlet accepts it (F9). - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"` - IsActive *bool `json:"is_active,omitempty"` - Search string `json:"search,omitempty"` - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` + Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"` + IsActive *bool `json:"is_active,omitempty"` + Search string `json:"search,omitempty"` + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` } type ListPaymentMethodsResponse struct { diff --git a/internal/contract/spin_game_contract.go b/internal/contract/spin_game_contract.go index 67b50b7..e41dc41 100644 --- a/internal/contract/spin_game_contract.go +++ b/internal/contract/spin_game_contract.go @@ -14,9 +14,7 @@ type SpinGameResponse struct { // SpinGameResponseData contains the game play result type SpinGameResponseData struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` - CoinsRemaining int64 `json:"coins_remaining"` - // Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining. - TokensRemaining int64 `json:"tokens_remaining"` + GamePlay GamePlayResponse `json:"game_play"` + PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` + TokensRemaining int64 `json:"tokens_remaining"` } diff --git a/internal/contract/wallet_contract.go b/internal/contract/wallet_contract.go deleted file mode 100644 index d261a95..0000000 --- a/internal/contract/wallet_contract.go +++ /dev/null @@ -1,31 +0,0 @@ -package contract - -// AdjustCustomerWalletRequest is POST /marketing/customers/:id/wallet/adjust -// (docs/prd-point-coin.md F7). -type AdjustCustomerWalletRequest struct { - // POINT or COIN. - Currency string `json:"currency"` - // Signed: positive adds to the balance, negative takes from it. Never zero. - Amount int64 `json:"amount"` - Reason string `json:"reason"` - // Optional. Sending the same key again returns the first result instead of - // adjusting twice, so a retried request is safe. - IdempotencyKey string `json:"idempotency_key"` -} - -// ExchangeCoinsRequest is POST /customer/wallet/exchange (docs/prd-point-coin.md F4). -// The Idempotency-Key header is required. -type ExchangeCoinsRequest struct { - Coins int64 `json:"coins" binding:"required,min=1"` - Pin string `json:"pin" binding:"required"` -} - -// TransferWalletRequest is POST /customer/wallet/transfer (docs/prd-point-coin.md F5). -// The Idempotency-Key header is required. -type TransferWalletRequest struct { - // POINT or COIN. - Currency string `json:"currency" binding:"required"` - Amount int64 `json:"amount" binding:"required,min=1"` - RecipientPhone string `json:"recipient_phone" binding:"required"` - Pin string `json:"pin" binding:"required"` -} diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 8446e86..87b9644 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -14,8 +14,6 @@ type PaymentMethodAnalytics struct { TotalAmount float64 `json:"total_amount"` OrderCount int64 `json:"order_count"` PaymentCount int64 `json:"payment_count"` - // EnakPoint used, for the EnakPoint method. - PointsUsed int64 `json:"points_used"` } // SalesAnalytics represents sales analytics data @@ -175,15 +173,15 @@ type BudgetCutOffWeek struct { // DashboardOverview represents dashboard overview data type DashboardOverview struct { - TotalSales float64 `json:"total_sales"` - TotalOrders int64 `json:"total_orders"` - AverageOrderValue float64 `json:"average_order_value"` - TotalCustomers int64 `json:"total_customers"` - VoidedOrders int64 `json:"voided_orders"` - RefundedOrders int64 `json:"refunded_orders"` - TotalItemSold int64 `json:"total_item_sold"` - TotalLowStock int64 `json:"total_low_stock"` - TotalProductActive int64 `json:"total_product_active"` + TotalSales float64 `json:"total_sales"` + TotalOrders int64 `json:"total_orders"` + AverageOrderValue float64 `json:"average_order_value"` + TotalCustomers int64 `json:"total_customers"` + VoidedOrders int64 `json:"voided_orders"` + RefundedOrders int64 `json:"refunded_orders"` + TotalItemSold int64 `json:"total_item_sold"` + TotalLowStock int64 `json:"total_low_stock"` + TotalProductActive int64 `json:"total_product_active"` } type ProfitLossAnalytics struct { diff --git a/internal/entities/campaign.go b/internal/entities/campaign.go index 5eabd76..bf76ac5 100644 --- a/internal/entities/campaign.go +++ b/internal/entities/campaign.go @@ -12,31 +12,10 @@ type CampaignType string const ( CampaignTypeReward CampaignType = "REWARD" CampaignTypePoints CampaignType = "POINTS" - CampaignTypeCoins CampaignType = "COINS" + CampaignTypeTokens CampaignType = "TOKENS" 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 ( @@ -52,7 +31,7 @@ type CampaignRewardType string const ( CampaignRewardTypePoints CampaignRewardType = "POINTS" - CampaignRewardTypeCoins CampaignRewardType = "COINS" + CampaignRewardTypeTokens CampaignRewardType = "TOKENS" CampaignRewardTypeReward CampaignRewardType = "REWARD" ) diff --git a/internal/entities/campaign_test.go b/internal/entities/campaign_test.go deleted file mode 100644 index 5dd1cd0..0000000 --- a/internal/entities/campaign_test.go +++ /dev/null @@ -1,18 +0,0 @@ -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("")) -} diff --git a/internal/entities/entities.go b/internal/entities/entities.go index 8ee2e10..a5d6973 100644 --- a/internal/entities/entities.go +++ b/internal/entities/entities.go @@ -44,12 +44,6 @@ func GetAllEntities() []interface{} { &ProductOutletPrice{}, &Expense{}, &CashAdvance{}, - // Wallet entities - &CustomerWallet{}, - &WalletTransaction{}, - &WalletLot{}, - &WalletLotAllocation{}, - &LoyaltySettingChange{}, } } diff --git a/internal/entities/game_play.go b/internal/entities/game_play.go index 026ca95..5ef61f6 100644 --- a/internal/entities/game_play.go +++ b/internal/entities/game_play.go @@ -12,7 +12,7 @@ type GamePlay struct { GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"` CustomerID uuid.UUID `gorm:"type:uuid;not null;index" json:"customer_id" validate:"required"` PrizeID *uuid.UUID `gorm:"type:uuid" json:"prize_id,omitempty"` - CoinsUsed int `gorm:"column:coins_used;default:0" json:"coins_used" validate:"min=0"` + TokenUsed int `gorm:"default:0" json:"token_used" validate:"min=0"` RandomSeed *string `gorm:"type:varchar(255)" json:"random_seed,omitempty"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` diff --git a/internal/entities/loyalty_setting_change.go b/internal/entities/loyalty_setting_change.go deleted file mode 100644 index db5e8f2..0000000 --- a/internal/entities/loyalty_setting_change.go +++ /dev/null @@ -1,34 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -// LoyaltySettingChange records one change to a loyalty setting: which key, from what, -// to what, by whom (docs/prd-point-coin.md F2). Append-only. -type LoyaltySettingChange struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` - // Nil for an organization setting. - OutletID *uuid.UUID `gorm:"type:uuid" json:"outlet_id"` - Key string `gorm:"not null;size:100" json:"key"` - // Nil when the key had no stored value, or has none any more. - OldValue *string `gorm:"type:text" json:"old_value"` - NewValue *string `gorm:"type:text" json:"new_value"` - ChangedBy uuid.UUID `gorm:"type:uuid;not null" json:"changed_by"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` -} - -func (c *LoyaltySettingChange) BeforeCreate(tx *gorm.DB) error { - if c.ID == uuid.Nil { - c.ID = uuid.New() - } - return nil -} - -func (LoyaltySettingChange) TableName() string { - return "loyalty_setting_changes" -} diff --git a/internal/entities/payment.go b/internal/entities/payment.go index f1bf058..1e7ea29 100644 --- a/internal/entities/payment.go +++ b/internal/entities/payment.go @@ -13,16 +13,13 @@ const ( PaymentMethodTypeCash PaymentMethodType = "cash" PaymentMethodTypeCard PaymentMethodType = "card" PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet" - // The system method for paying with EnakPoint (docs/prd-point-coin.md F9). One per - // organization; it cannot be created, deleted or retyped through the API. - PaymentMethodTypePoint PaymentMethodType = "point" ) type PaymentMethod struct { ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` Name string `gorm:"not null;size:100" json:"name" validate:"required,min=1,max=100"` - Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet point"` + Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet"` Processor *string `gorm:"size:100" json:"processor"` Configuration Metadata `gorm:"type:jsonb;default:'{}'" json:"configuration"` IsActive bool `gorm:"default:true" json:"is_active"` @@ -72,16 +69,12 @@ type Payment struct { SplitType *SplitType `gorm:"size:20" json:"split_type,omitempty"` SplitDescription *string `gorm:"size:255" json:"split_description,omitempty"` RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"` - // Set only for a payment with EnakPoint: how many were used, and the rupiah value of - // one then, frozen so a refund returns exactly what was used. - PointsUsed *int64 `json:"points_used,omitempty"` - PointValue *float64 `gorm:"type:decimal(10,2)" json:"point_value,omitempty"` - RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"` - RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"` - RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"` - Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"` + RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"` + RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"` + Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"` PaymentMethod PaymentMethod `gorm:"foreignKey:PaymentMethodID" json:"payment_method,omitempty"` diff --git a/internal/entities/wallet.go b/internal/entities/wallet.go deleted file mode 100644 index 7b78cb2..0000000 --- a/internal/entities/wallet.go +++ /dev/null @@ -1,113 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -// CustomerWallet holds a customer's EnakPoint and EnakCoin balances. The row is also -// the lock every wallet operation for the customer takes first, so concurrent -// operations on one customer queue up instead of spending the same balance twice. -// -// Balances are never written directly: they only move together with a ledger row, and -// only through the wallet processor. -type CustomerWallet struct { - CustomerID uuid.UUID `gorm:"type:uuid;primary_key" json:"customer_id"` - OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` - PointBalance int64 `gorm:"not null;default:0" json:"point_balance"` - CoinBalance int64 `gorm:"not null;default:0" json:"coin_balance"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` -} - -func (CustomerWallet) TableName() string { - return "customer_wallets" -} - -// WalletTransaction is one ledger row. The ledger is append-only: a correction is a -// new row pointing at the one it corrects, never an update. -type WalletTransaction struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` - CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"` - Currency string `gorm:"not null;size:10" json:"currency"` - Type string `gorm:"not null;size:30" json:"type"` - // Signed: positive credits the wallet, negative debits it. - Amount int64 `gorm:"not null" json:"amount"` - BalanceAfter int64 `gorm:"not null" json:"balance_after"` - GroupID *uuid.UUID `gorm:"type:uuid" json:"group_id"` - - // Where the value came from (credit) or went to (debit). - ReferenceType string `gorm:"not null;size:30" json:"reference_type"` - ReferenceID uuid.UUID `gorm:"type:uuid;not null" json:"reference_id"` - - CounterpartyCustomerID *uuid.UUID `gorm:"type:uuid" json:"counterparty_customer_id"` - ReversesTransactionID *uuid.UUID `gorm:"type:uuid" json:"reverses_transaction_id"` - OutletID *uuid.UUID `gorm:"type:uuid" json:"outlet_id"` - CreatedByUser *uuid.UUID `gorm:"type:uuid" json:"created_by_user"` - Reason *string `gorm:"size:255" json:"reason"` - - // Frozen at creation, so later renames do not rewrite history. - Description string `gorm:"not null;size:255" json:"description"` - Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` - IdempotencyKey *string `gorm:"size:100;unique" json:"idempotency_key"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` -} - -func (t *WalletTransaction) BeforeCreate(tx *gorm.DB) error { - if t.ID == uuid.Nil { - t.ID = uuid.New() - } - // A nil map would be stored as JSON null rather than an empty object. - if t.Metadata == nil { - t.Metadata = Metadata{} - } - return nil -} - -func (WalletTransaction) TableName() string { - return "wallet_transactions" -} - -// WalletLot is one credited piece of balance with its own expiry (K9). Debits draw from -// the lots that expire soonest. A lot created by a transfer, exchange or refund carries -// the expiry of the lot it came from and points back at it through OriginLotID. -type WalletLot struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` - CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"` - Currency string `gorm:"not null;size:10" json:"currency"` - SourceTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"source_transaction_id"` - OriginLotID *uuid.UUID `gorm:"type:uuid" json:"origin_lot_id"` - OriginalAmount int64 `gorm:"not null" json:"original_amount"` - // A cache of OriginalAmount minus the lot's allocations, and the only wallet column - // that is ever updated. - RemainingAmount int64 `gorm:"not null" json:"remaining_amount"` - // Nil means the lot never expires. - ExpiresAt *time.Time `json:"expires_at"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` -} - -func (l *WalletLot) BeforeCreate(tx *gorm.DB) error { - if l.ID == uuid.Nil { - l.ID = uuid.New() - } - return nil -} - -func (WalletLot) TableName() string { - return "wallet_lots" -} - -// WalletLotAllocation records how much a debit ledger row drew from one lot. -type WalletLotAllocation struct { - TransactionID uuid.UUID `gorm:"type:uuid;primary_key" json:"transaction_id"` - LotID uuid.UUID `gorm:"type:uuid;primary_key" json:"lot_id"` - Amount int64 `gorm:"not null" json:"amount"` -} - -func (WalletLotAllocation) TableName() string { - return "wallet_lot_allocations" -} diff --git a/internal/handler/customer_device_handler.go b/internal/handler/customer_device_handler.go deleted file mode 100644 index da03f32..0000000 --- a/internal/handler/customer_device_handler.go +++ /dev/null @@ -1,45 +0,0 @@ -package handler - -import ( - "github.com/gin-gonic/gin" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" -) - -// CustomerDeviceHandler serves /customer/devices, where the customer app registers -// the FCM token of each device it runs on. -type CustomerDeviceHandler struct { - devices service.CustomerDeviceService -} - -func NewCustomerDeviceHandler(devices service.CustomerDeviceService) *CustomerDeviceHandler { - return &CustomerDeviceHandler{devices: devices} -} - -// Register is PUT /customer/devices. -func (h *CustomerDeviceHandler) Register(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerDeviceHandler::Register") - if !ok { - return - } - var req contract.RegisterCustomerDeviceRequest - if err := c.ShouldBindJSON(&req); err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "device_id and fcm_token are required"), - }), "CustomerDeviceHandler::Register") - return - } - util.HandleResponse(c.Writer, c.Request, h.devices.Register(c.Request.Context(), customerID, &req), "CustomerDeviceHandler::Register") -} - -// Unregister is DELETE /customer/devices/:device_id, for logout. -func (h *CustomerDeviceHandler) Unregister(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerDeviceHandler::Unregister") - if !ok { - return - } - util.HandleResponse(c.Writer, c.Request, h.devices.Unregister(c.Request.Context(), customerID, c.Param("device_id")), "CustomerDeviceHandler::Unregister") -} diff --git a/internal/handler/customer_order_payment_handler.go b/internal/handler/customer_order_payment_handler.go deleted file mode 100644 index 29f7071..0000000 --- a/internal/handler/customer_order_payment_handler.go +++ /dev/null @@ -1,35 +0,0 @@ -package handler - -import ( - "github.com/gin-gonic/gin" - - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" -) - -// CustomerOrderPaymentHandler serves POST /customer/orders/:id/pay-with-points -// (docs/prd-point-coin.md F9). The body holds the PIN, so it is never logged. -type CustomerOrderPaymentHandler struct { - payments service.CustomerOrderPaymentService -} - -func NewCustomerOrderPaymentHandler(payments service.CustomerOrderPaymentService) *CustomerOrderPaymentHandler { - return &CustomerOrderPaymentHandler{payments: payments} -} - -func (h *CustomerOrderPaymentHandler) PayWithPoints(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerOrderPaymentHandler::PayWithPoints") - if !ok { - return - } - orderID, ok := parseUUIDParam(c, "id", "CustomerOrderPaymentHandler::PayWithPoints") - if !ok { - return - } - var req contract.PayWithPointsRequest - if !bindPinRequest(c, &req, "CustomerOrderPaymentHandler::PayWithPoints") { - return - } - util.HandleResponse(c.Writer, c.Request, h.payments.PayWithPoints(c.Request.Context(), customerID, orderID, &req, pinRequestInfo(c)), "CustomerOrderPaymentHandler::PayWithPoints") -} diff --git a/internal/handler/customer_pin_handler.go b/internal/handler/customer_pin_handler.go deleted file mode 100644 index c0f9185..0000000 --- a/internal/handler/customer_pin_handler.go +++ /dev/null @@ -1,151 +0,0 @@ -package handler - -import ( - "strconv" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - - "apskel-pos-be/internal/appcontext" - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" -) - -// CustomerPinHandler serves /customer/pin and the dashboard's PIN endpoints -// (docs/prd-point-coin.md F11). Request bodies hold PINs, so nothing here logs a body, -// and binding errors are reported without the values sent. -type CustomerPinHandler struct { - pinService service.CustomerPinService -} - -func NewCustomerPinHandler(pinService service.CustomerPinService) *CustomerPinHandler { - return &CustomerPinHandler{pinService: pinService} -} - -func (h *CustomerPinHandler) Status(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerPinHandler::Status") - if !ok { - return - } - util.HandleResponse(c.Writer, c.Request, h.pinService.Status(c.Request.Context(), customerID), "CustomerPinHandler::Status") -} - -func (h *CustomerPinHandler) RequestOtp(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerPinHandler::RequestOtp") - if !ok { - return - } - var req contract.RequestPinOtpRequest - if !bindPinRequest(c, &req, "CustomerPinHandler::RequestOtp") { - return - } - util.HandleResponse(c.Writer, c.Request, h.pinService.RequestOtp(c.Request.Context(), customerID, &req), "CustomerPinHandler::RequestOtp") -} - -func (h *CustomerPinHandler) CreatePin(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerPinHandler::CreatePin") - if !ok { - return - } - var req contract.CreateCustomerPinRequest - if !bindPinRequest(c, &req, "CustomerPinHandler::CreatePin") { - return - } - util.HandleResponse(c.Writer, c.Request, h.pinService.CreatePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::CreatePin") -} - -func (h *CustomerPinHandler) ChangePin(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ChangePin") - if !ok { - return - } - var req contract.ChangeCustomerPinRequest - if !bindPinRequest(c, &req, "CustomerPinHandler::ChangePin") { - return - } - util.HandleResponse(c.Writer, c.Request, h.pinService.ChangePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ChangePin") -} - -func (h *CustomerPinHandler) ResetPin(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ResetPin") - if !ok { - return - } - var req contract.ResetCustomerPinRequest - if !bindPinRequest(c, &req, "CustomerPinHandler::ResetPin") { - return - } - util.HandleResponse(c.Writer, c.Request, h.pinService.ResetPin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ResetPin") -} - -// RemovePin is DELETE /marketing/customers/:id/pin. -func (h *CustomerPinHandler) RemovePin(c *gin.Context) { - customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::RemovePin") - if !ok { - return - } - var req contract.RemoveCustomerPinRequest - if !bindPinRequest(c, &req, "CustomerPinHandler::RemovePin") { - return - } - ctx := c.Request.Context() - util.HandleResponse(c.Writer, c.Request, h.pinService.RemovePin(ctx, appcontext.FromGinContext(ctx), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::RemovePin") -} - -// ListSecurityEvents is GET /marketing/customers/:id/security-events. -func (h *CustomerPinHandler) ListSecurityEvents(c *gin.Context) { - customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::ListSecurityEvents") - if !ok { - return - } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) - ctx := c.Request.Context() - util.HandleResponse(c.Writer, c.Request, h.pinService.ListSecurityEvents(ctx, appcontext.FromGinContext(ctx), customerID, page, limit), "CustomerPinHandler::ListSecurityEvents") -} - -// bindPinRequest binds a JSON body. The error it reports names what is wrong, never the -// values, since those can be PINs. -func bindPinRequest(c *gin.Context, req interface{}, method string) bool { - if err := c.ShouldBindJSON(req); err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "invalid request body: required fields are missing or have the wrong type"), - }), method) - return false - } - return true -} - -// customerIDFromGin reads the customer set by CustomerAuthMiddleware. -func customerIDFromGin(c *gin.Context, method string) (uuid.UUID, bool) { - raw, _ := c.Get("customer_id") - s, _ := raw.(string) - id, err := uuid.Parse(s) - if err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"), - }), method) - return uuid.Nil, false - } - return id, true -} - -func pinRequestInfo(c *gin.Context) models.CustomerPinRequestInfo { - return models.CustomerPinRequestInfo{IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent()} -} - -// IssuePaymentCode is POST /customer/wallet/payment-code. -func (h *CustomerPinHandler) IssuePaymentCode(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerPinHandler::IssuePaymentCode") - if !ok { - return - } - var req contract.IssuePaymentCodeRequest - if !bindPinRequest(c, &req, "CustomerPinHandler::IssuePaymentCode") { - return - } - util.HandleResponse(c.Writer, c.Request, h.pinService.IssuePaymentCode(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::IssuePaymentCode") -} diff --git a/internal/handler/customer_points_handler.go b/internal/handler/customer_points_handler.go index 0df8ff2..8e98a26 100644 --- a/internal/handler/customer_points_handler.go +++ b/internal/handler/customer_points_handler.go @@ -1,14 +1,9 @@ package handler import ( - "errors" - "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" "apskel-pos-be/internal/service" "apskel-pos-be/internal/util" @@ -51,7 +46,7 @@ func (h *CustomerPointsHandler) GetCustomerPoints(c *gin.Context) { if err != nil { logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerPoints -> service call failed") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()), + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), }), "CustomerPointsHandler::GetCustomerPoints") return } @@ -85,7 +80,7 @@ func (h *CustomerPointsHandler) GetCustomerTokens(c *gin.Context) { if err != nil { logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerTokens -> service call failed") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()), + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), }), "CustomerPointsHandler::GetCustomerTokens") return } @@ -119,7 +114,7 @@ func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) { if err != nil { logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWallet -> service call failed") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()), + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), }), "CustomerPointsHandler::GetCustomerWallet") return } @@ -156,74 +151,3 @@ func (h *CustomerPointsHandler) GetFerrisWheelGame(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetFerrisWheelGame") } - -// GetCustomerWalletTransactions is GET /customer/wallet/transactions: the ledger, newest -// first, filterable by currency, type and date (docs/prd-point-coin.md F6). -func (h *CustomerPointsHandler) GetCustomerWalletTransactions(c *gin.Context) { - ctx := c.Request.Context() - - customerID, ok := c.Get("customer_id") - customerIDStr, isString := customerID.(string) - if !ok || !isString { - logger.FromContext(ctx).Error("Customer ID not found in context") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"), - }), "CustomerPointsHandler::GetCustomerWalletTransactions") - return - } - - var query models.ListCustomerWalletTransactionsQuery - if err := c.ShouldBindQuery(&query); err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()), - }), "CustomerPointsHandler::GetCustomerWalletTransactions") - return - } - - response, err := h.customerPointsService.GetCustomerWalletTransactions(ctx, customerIDStr, query) - if err != nil { - logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWalletTransactions -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()), - }), "CustomerPointsHandler::GetCustomerWalletTransactions") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWalletTransactions") -} - -// walletErrorCode tells a bad request apart from a server failure on the wallet -// endpoints. -func walletErrorCode(err error) string { - switch { - case errors.Is(err, processor.ErrInvalidWalletQuery): - return constants.ValidationErrorCode - case errors.Is(err, repository.ErrWalletNotFound): - return constants.NotFoundErrorCode - default: - return constants.InternalServerErrorCode - } -} - -// GetCustomerWalletExpiring is GET /customer/wallet/expiring: what will expire, per -// currency and day (docs/prd-point-coin.md F6). -func (h *CustomerPointsHandler) GetCustomerWalletExpiring(c *gin.Context) { - ctx := c.Request.Context() - customerID, ok := c.Get("customer_id") - customerIDStr, isString := customerID.(string) - if !ok || !isString { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"), - }), "CustomerPointsHandler::GetCustomerWalletExpiring") - return - } - response, err := h.customerPointsService.GetCustomerWalletExpiring(ctx, customerIDStr) - if err != nil { - logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWalletExpiring -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()), - }), "CustomerPointsHandler::GetCustomerWalletExpiring") - return - } - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWalletExpiring") -} diff --git a/internal/handler/customer_wallet_db_test.go b/internal/handler/customer_wallet_db_test.go deleted file mode 100644 index d9e2467..0000000 --- a/internal/handler/customer_wallet_db_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package handler - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/gin-gonic/gin" - "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" - applogger "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" - "apskel-pos-be/internal/service" -) - -// Drives the customer wallet endpoints over HTTP down to Postgres, to show the app -// gets the right numbers from the new endpoints and from the old /points, /tokens -// and /wallet shapes. Needs TEST_DATABASE_URL pointing at a migrated database; see -// internal/repository/wallet_repository_test.go. -func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) { - dsn := os.Getenv("TEST_DATABASE_URL") - if dsn == "" { - t.Skip("TEST_DATABASE_URL not set") - } - applogger.Setup("fatal", "json") // the handlers log their errors; the server sets this up at startup - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) - require.NoError(t, err) - - org, customer, other := uuid.New(), uuid.New(), uuid.New() - customers := []uuid.UUID{customer, other} - 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 (?, 'wallet api test', 'basic')`, org) - exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, customer, org, other, org) - exec(`INSERT INTO organization_settings (organization_id, key, value) VALUES (?, ?, '100')`, org, constants.LoyaltyPointValueKey) - 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 organization_settings WHERE organization_id = ?`, org) - db.Exec(`DELETE FROM organizations WHERE id = ?`, org) - }) - - // Balance: 300 earned in two lots that expire on the same Jakarta day but on - // different UTC days, 8 coins, 50 paid, and 20 in a lot that has expired but - // that the expiry job has not processed. - wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) - txm := repository.NewTxManager(db) - next := time.Now().Year() + 1 - lateNightUTC := time.Date(next, 7, 1, 17, 30, 0, 0, time.UTC) // 2 July 00:30 WIB - afternoonUTC := time.Date(next, 7, 2, 10, 0, 0, 0, time.UTC) // 2 July 17:00 WIB - expired := time.Now().Add(-time.Hour) - outlet, order, payment := uuid.New(), uuid.New(), uuid.New() - require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { - if _, err := wallet.Credit(ctx, processor.WalletCreditInput{ - WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypeEarn, Amount: 300, ReferenceType: constants.WalletRefTypeOrder, - ReferenceID: order, OutletID: &outlet, Description: "Belanja #ORD-1"}, - Lots: []processor.WalletLotInput{{Amount: 200, ExpiresAt: &afternoonUTC}, {Amount: 100, ExpiresAt: &lateNightUTC}}, - }); err != nil { - return err - } - if _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ - CustomerID: customer, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration, - Amount: 8, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: customer, - Description: "Saldo awal dari sistem lama"}}); err != nil { - return err - } - if _, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{ - CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, - Amount: 50, ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, OutletID: &outlet, - Description: "Bayar #ORD-2"}}); err != nil { - return err - } - _, err := wallet.Credit(ctx, processor.WalletCreditInput{ - WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypeEarn, Amount: 20, ReferenceType: constants.WalletRefTypeOrder, - ReferenceID: uuid.New(), OutletID: &outlet, Description: "Belanja #ORD-0"}, - Lots: []processor.WalletLotInput{{Amount: 20, ExpiresAt: &expired}}, - }) - return err - })) - - h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor( - processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), - nil, - ))) - gin.SetMode(gin.TestMode) - router := gin.New() - as := func(id uuid.UUID) gin.HandlerFunc { - return func(c *gin.Context) { c.Set("customer_id", id.String()) } - } - for _, who := range []struct { - prefix string - id uuid.UUID - }{{"/a", customer}, {"/b", other}, {"/gone", uuid.New()}} { - g := router.Group(who.prefix, as(who.id)) - g.GET("/points", h.GetCustomerPoints) - g.GET("/tokens", h.GetCustomerTokens) - g.GET("/wallet", h.GetCustomerWallet) - g.GET("/wallet/transactions", h.GetCustomerWalletTransactions) - } - get := func(path string) (int, map[string]any) { - t.Helper() - rec := httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) - var body map[string]any - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), rec.Body.String()) - return rec.Code, body - } - // The balance endpoints wrap their data in {status, message, data}. - inner := func(body map[string]any) map[string]any { - return body["data"].(map[string]any)["data"].(map[string]any) - } - - // Old app: /points and /tokens keep their shape and show the spendable balance. - status, body := get("/a/points") - require.Equal(t, http.StatusOK, status, body) - assert.EqualValues(t, 250, inner(body)["total_points"], "300 - 50; the expired 20 is not spendable") - assert.Len(t, inner(body)["points_history"], 3) - - status, body = get("/a/tokens") - require.Equal(t, http.StatusOK, status, body) - assert.EqualValues(t, 8, inner(body)["total_tokens"]) - - // /wallet: new fields, with the old ones still there. - status, body = get("/a/wallet") - require.Equal(t, http.StatusOK, status, body) - w := inner(body) - assert.EqualValues(t, 250, w["point_balance"]) - assert.EqualValues(t, 8, w["coin_balance"]) - assert.EqualValues(t, 100, w["point_value"]) - assert.EqualValues(t, 25000, w["point_discount_value"]) - nearest := w["nearest_expiring"].(map[string]any) - // The payment took 50 from the lot expiring first, leaving 50 + 200. - assert.Equal(t, map[string]any{"amount": float64(250), "date": time.Date(next, 7, 2, 0, 0, 0, 0, time.UTC).Format("2006-01-02")}, - nearest["point"], "both lots fall on 2 July in Jakarta although they are on different UTC days") - assert.Nil(t, nearest["coin"]) - for _, legacy := range []string{"total_points", "total_tokens", "points_history", "tokens_history", "last_updated"} { - assert.Contains(t, w, legacy) - } - assert.EqualValues(t, 250, w["total_points"]) - - recent := w["recent_transactions"].([]any) - require.Len(t, recent, 4) - for _, raw := range recent { - tx := raw.(map[string]any) - assert.NotContains(t, tx, "counterparty_customer_id") - assert.NotContains(t, tx, "metadata") - if tx["amount"].(float64) > 0 { - assert.Contains(t, tx, "source", tx["type"]) - assert.NotContains(t, tx, "destination") - } else { - assert.Equal(t, map[string]any{"type": "PAYMENT", "id": payment.String()}, tx["destination"]) - assert.NotContains(t, tx, "source") - } - } - - // History with filters. - status, body = get("/a/wallet/transactions?currency=POINT&type=EARN&limit=1&page=2") - require.Equal(t, http.StatusOK, status, body) - page := body["data"].(map[string]any) - assert.Equal(t, map[string]any{"page": float64(2), "limit": float64(1), "total_count": float64(2), "total_pages": float64(2)}, page["pagination"]) - rows := page["data"].([]any) - require.Len(t, rows, 1) - first := rows[0].(map[string]any) - assert.Equal(t, "Belanja #ORD-1", first["description"], "newest first, so page 2 is the older earning") - assert.Equal(t, map[string]any{"type": "ORDER", "id": order.String()}, first["source"]) - assert.Equal(t, lateNightUTC.Format(time.RFC3339), parseTime(t, first["expires_at"]).UTC().Format(time.RFC3339)) - assert.Len(t, first["lots"], 2) - - today := time.Now().In(time.FixedZone("WIB", 7*3600)).Format("2006-01-02") - status, body = get("/a/wallet/transactions?from=" + today + "&to=" + today + "&type=PAYMENT,MIGRATION") - require.Equal(t, http.StatusOK, status, body) - assert.EqualValues(t, 2, body["data"].(map[string]any)["pagination"].(map[string]any)["total_count"]) - - status, _ = get("/a/wallet/transactions?currency=GOLD") - assert.Equal(t, http.StatusBadRequest, status) - status, _ = get("/a/wallet/transactions?limit=abc") - assert.Equal(t, http.StatusBadRequest, status) - - // A customer who has never had a wallet sees zeros, not an error. - status, body = get("/b/wallet") - require.Equal(t, http.StatusOK, status, body) - assert.EqualValues(t, 0, inner(body)["point_balance"]) - assert.Equal(t, []any{}, inner(body)["recent_transactions"]) - status, body = get("/b/wallet/transactions") - require.Equal(t, http.StatusOK, status, body) - assert.Equal(t, []any{}, body["data"].(map[string]any)["data"]) - - // A token for a customer that no longer exists. - status, _ = get("/gone/wallet") - assert.Equal(t, http.StatusNotFound, status) -} - -func parseTime(t *testing.T, v any) time.Time { - t.Helper() - s, ok := v.(string) - require.True(t, ok, "expected a time string, got %v", v) - parsed, err := time.Parse(time.RFC3339Nano, s) - require.NoError(t, err) - return parsed -} diff --git a/internal/handler/customer_wallet_handler.go b/internal/handler/customer_wallet_handler.go deleted file mode 100644 index 90aed18..0000000 --- a/internal/handler/customer_wallet_handler.go +++ /dev/null @@ -1,92 +0,0 @@ -package handler - -import ( - "strconv" - "strings" - - "github.com/gin-gonic/gin" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" -) - -// The header exchanges and transfers require (docs/prd-point-coin.md F4, F5), and the -// X- form the rest of the API uses, which is accepted too. -const ( - idempotencyKeyHeader = "Idempotency-Key" - legacyIdempotencyKeyHeader = "X-Idempotency-Key" -) - -// CustomerWalletHandler serves what the customer app does with its own wallet. Bodies -// hold the PIN, so they are never logged. -type CustomerWalletHandler struct { - wallets service.CustomerWalletService -} - -func NewCustomerWalletHandler(wallets service.CustomerWalletService) *CustomerWalletHandler { - return &CustomerWalletHandler{wallets: wallets} -} - -// PreviewExchange is GET /customer/wallet/exchange/preview?coins=. -func (h *CustomerWalletHandler) PreviewExchange(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::PreviewExchange") - if !ok { - return - } - var coins int64 - if raw := c.Query("coins"); raw != "" { - var err error - coins, err = strconv.ParseInt(raw, 10, 64) - if err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "coins must be a whole number"), - }), "CustomerWalletHandler::PreviewExchange") - return - } - } - util.HandleResponse(c.Writer, c.Request, h.wallets.PreviewExchange(c.Request.Context(), customerID, coins), "CustomerWalletHandler::PreviewExchange") -} - -// Exchange is POST /customer/wallet/exchange. -func (h *CustomerWalletHandler) Exchange(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::Exchange") - if !ok { - return - } - var req contract.ExchangeCoinsRequest - if !bindPinRequest(c, &req, "CustomerWalletHandler::Exchange") { - return - } - util.HandleResponse(c.Writer, c.Request, h.wallets.Exchange(c.Request.Context(), customerID, &req, idempotencyKey(c), pinRequestInfo(c)), "CustomerWalletHandler::Exchange") -} - -func idempotencyKey(c *gin.Context) string { - if key := strings.TrimSpace(c.GetHeader(idempotencyKeyHeader)); key != "" { - return key - } - return strings.TrimSpace(c.GetHeader(legacyIdempotencyKeyHeader)) -} - -// TransferRecipient is GET /customer/wallet/transfer/recipient?phone=. -func (h *CustomerWalletHandler) TransferRecipient(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::TransferRecipient") - if !ok { - return - } - util.HandleResponse(c.Writer, c.Request, h.wallets.TransferRecipient(c.Request.Context(), customerID, c.Query("phone")), "CustomerWalletHandler::TransferRecipient") -} - -// Transfer is POST /customer/wallet/transfer. -func (h *CustomerWalletHandler) Transfer(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::Transfer") - if !ok { - return - } - var req contract.TransferWalletRequest - if !bindPinRequest(c, &req, "CustomerWalletHandler::Transfer") { - return - } - util.HandleResponse(c.Writer, c.Request, h.wallets.Transfer(c.Request.Context(), customerID, &req, idempotencyKey(c), pinRequestInfo(c)), "CustomerWalletHandler::Transfer") -} diff --git a/internal/handler/gamification_handler.go b/internal/handler/gamification_handler.go index 4c3c606..4344a35 100644 --- a/internal/handler/gamification_handler.go +++ b/internal/handler/gamification_handler.go @@ -9,6 +9,7 @@ import ( "apskel-pos-be/internal/validator" "github.com/gin-gonic/gin" + "github.com/google/uuid" ) type GamificationHandler struct { @@ -26,6 +27,239 @@ func NewGamificationHandler( } } +// Customer Points Handlers +func (h *GamificationHandler) CreateCustomerPoints(c *gin.Context) { + ctx := c.Request.Context() + + var req contract.CreateCustomerPointsRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerPoints -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerPoints") + return + } + + validationError, validationErrorCode := h.gamificationValidator.ValidateCreateCustomerPointsRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateCustomerPoints -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerPoints") + return + } + + response, err := h.gamificationService.CreateCustomerPoints(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerPoints -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::CreateCustomerPoints") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateCustomerPoints") +} + +func (h *GamificationHandler) GetCustomerPoints(c *gin.Context) { + ctx := c.Request.Context() + + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPoints -> invalid ID") + validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerPoints") + return + } + + response, err := h.gamificationService.GetCustomerPoints(ctx, id) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPoints -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::GetCustomerPoints") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerPoints") +} + +func (h *GamificationHandler) GetCustomerPointsByCustomerID(c *gin.Context) { + ctx := c.Request.Context() + customerIDStr := c.Param("customer_id") + customerID, err := uuid.Parse(customerIDStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPointsByCustomerID -> invalid customer ID") + validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerPointsByCustomerID") + return + } + + response, err := h.gamificationService.GetCustomerPointsByCustomerID(ctx, customerID) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPointsByCustomerID -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::GetCustomerPointsByCustomerID") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerPointsByCustomerID") +} + +func (h *GamificationHandler) ListCustomerPoints(c *gin.Context) { + ctx := c.Request.Context() + var req contract.ListCustomerPointsRequest + if err := c.ShouldBindQuery(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerPoints -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerPoints") + return + } + + validationError, validationErrorCode := h.gamificationValidator.ValidateListCustomerPointsRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListCustomerPoints -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerPoints") + return + } + + response, err := h.gamificationService.ListCustomerPoints(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerPoints -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::ListCustomerPoints") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListCustomerPoints") +} + +func (h *GamificationHandler) UpdateCustomerPoints(c *gin.Context) { + ctx := c.Request.Context() + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerPoints -> invalid ID") + validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints") + return + } + + var req contract.UpdateCustomerPointsRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerPoints -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints") + return + } + + validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateCustomerPointsRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateCustomerPoints -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints") + return + } + + response, err := h.gamificationService.UpdateCustomerPoints(ctx, id, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerPoints -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::UpdateCustomerPoints") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateCustomerPoints") +} + +func (h *GamificationHandler) DeleteCustomerPoints(c *gin.Context) { + ctx := c.Request.Context() + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerPoints -> invalid ID") + validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteCustomerPoints") + return + } + + err = h.gamificationService.DeleteCustomerPoints(ctx, id) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerPoints -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::DeleteCustomerPoints") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteCustomerPoints") +} + +func (h *GamificationHandler) AddCustomerPoints(c *gin.Context) { + ctx := c.Request.Context() + customerIDStr := c.Param("customer_id") + customerID, err := uuid.Parse(customerIDStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> invalid customer ID") + validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints") + return + } + + var req contract.AddCustomerPointsRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints") + return + } + + validationError, validationErrorCode := h.gamificationValidator.ValidateAddCustomerPointsRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::AddCustomerPoints -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints") + return + } + + response, err := h.gamificationService.AddCustomerPoints(ctx, customerID, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::AddCustomerPoints") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::AddCustomerPoints") +} + +func (h *GamificationHandler) DeductCustomerPoints(c *gin.Context) { + ctx := c.Request.Context() + customerIDStr := c.Param("customer_id") + customerID, err := uuid.Parse(customerIDStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> invalid customer ID") + validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints") + return + } + + var req contract.DeductCustomerPointsRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints") + return + } + + validationError, validationErrorCode := h.gamificationValidator.ValidateDeductCustomerPointsRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::DeductCustomerPoints -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints") + return + } + + response, err := h.gamificationService.DeductCustomerPoints(ctx, customerID, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::DeductCustomerPoints") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::DeductCustomerPoints") +} + // Play Game Handler func (h *GamificationHandler) PlayGame(c *gin.Context) { ctx := c.Request.Context() @@ -54,3 +288,240 @@ func (h *GamificationHandler) PlayGame(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::PlayGame") } + +// Additional handler methods for other gamification features +func (h *GamificationHandler) CreateCustomerTokens(c *gin.Context) { + ctx := c.Request.Context() + var req contract.CreateCustomerTokensRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerTokens -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerTokens") + return + } + + validationError, validationErrorCode := h.gamificationValidator.ValidateCreateCustomerTokensRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateCustomerTokens -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerTokens") + return + } + + response, err := h.gamificationService.CreateCustomerTokens(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerTokens -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::CreateCustomerTokens") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateCustomerTokens") +} + +func (h *GamificationHandler) GetCustomerTokens(c *gin.Context) { + ctx := c.Request.Context() + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokens -> invalid ID") + validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerTokens") + return + } + + response, err := h.gamificationService.GetCustomerTokens(ctx, id) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokens -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::GetCustomerTokens") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerTokens") +} + +func (h *GamificationHandler) GetCustomerTokensByCustomerIDAndType(c *gin.Context) { + ctx := c.Request.Context() + customerIDStr := c.Param("customer_id") + customerID, err := uuid.Parse(customerIDStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokensByCustomerIDAndType -> invalid customer ID") + validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerTokensByCustomerIDAndType") + return + } + + tokenType := c.Param("token_type") + + response, err := h.gamificationService.GetCustomerTokensByCustomerIDAndType(ctx, customerID, tokenType) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokensByCustomerIDAndType -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::GetCustomerTokensByCustomerIDAndType") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerTokensByCustomerIDAndType") +} + +func (h *GamificationHandler) ListCustomerTokens(c *gin.Context) { + ctx := c.Request.Context() + var req contract.ListCustomerTokensRequest + if err := c.ShouldBindQuery(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerTokens -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerTokens") + return + } + + validationError, validationErrorCode := h.gamificationValidator.ValidateListCustomerTokensRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListCustomerTokens -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerTokens") + return + } + + response, err := h.gamificationService.ListCustomerTokens(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerTokens -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::ListCustomerTokens") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListCustomerTokens") +} + +func (h *GamificationHandler) UpdateCustomerTokens(c *gin.Context) { + ctx := c.Request.Context() + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerTokens -> invalid ID") + validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens") + return + } + + var req contract.UpdateCustomerTokensRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerTokens -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens") + return + } + + validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateCustomerTokensRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateCustomerTokens -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens") + return + } + + response, err := h.gamificationService.UpdateCustomerTokens(ctx, id, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerTokens -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::UpdateCustomerTokens") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateCustomerTokens") +} + +func (h *GamificationHandler) DeleteCustomerTokens(c *gin.Context) { + ctx := c.Request.Context() + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerTokens -> invalid ID") + validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteCustomerTokens") + return + } + + err = h.gamificationService.DeleteCustomerTokens(ctx, id) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerTokens -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::DeleteCustomerTokens") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteCustomerTokens") +} + +func (h *GamificationHandler) AddCustomerTokens(c *gin.Context) { + ctx := c.Request.Context() + customerIDStr := c.Param("customer_id") + customerID, err := uuid.Parse(customerIDStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> invalid customer ID") + validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens") + return + } + + tokenType := c.Param("token_type") + + var req contract.AddCustomerTokensRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens") + return + } + + validationError, validationErrorCode := h.gamificationValidator.ValidateAddCustomerTokensRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::AddCustomerTokens -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens") + return + } + + response, err := h.gamificationService.AddCustomerTokens(ctx, customerID, tokenType, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::AddCustomerTokens") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::AddCustomerTokens") +} + +func (h *GamificationHandler) DeductCustomerTokens(c *gin.Context) { + ctx := c.Request.Context() + customerIDStr := c.Param("customer_id") + customerID, err := uuid.Parse(customerIDStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> invalid customer ID") + validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens") + return + } + + tokenType := c.Param("token_type") + + var req contract.DeductCustomerTokensRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens") + return + } + + validationError, validationErrorCode := h.gamificationValidator.ValidateDeductCustomerTokensRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::DeductCustomerTokens -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens") + return + } + + response, err := h.gamificationService.DeductCustomerTokens(ctx, customerID, tokenType, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::DeductCustomerTokens") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::DeductCustomerTokens") +} diff --git a/internal/handler/loyalty_settings_db_test.go b/internal/handler/loyalty_settings_db_test.go deleted file mode 100644 index c427002..0000000 --- a/internal/handler/loyalty_settings_db_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package handler - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/gin-gonic/gin" - "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/appcontext" - applogger "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/middleware" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" - "apskel-pos-be/internal/service" -) - -// Drives GET/PUT /outlets/:id/loyalty-settings over HTTP down to Postgres. Needs -// TEST_DATABASE_URL pointing at a migrated database; see -// internal/repository/wallet_repository_test.go. -func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) { - dsn := os.Getenv("TEST_DATABASE_URL") - if dsn == "" { - t.Skip("TEST_DATABASE_URL not set") - } - applogger.Setup("fatal", "json") - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) - require.NoError(t, err) - - org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New() - 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 (?, 'A', 'basic'), (?, 'B', 'basic')`, org, otherOrg) - exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org) - t.Cleanup(func() { - db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg}) - db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet) - db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org) - db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet) - db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg}) - }) - - settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db)) - h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db))) - auth := middleware.NewAuthMiddleware(nil) - - gin.SetMode(gin.TestMode) - router := gin.New() - as := func(orgID uuid.UUID, role string) gin.HandlerFunc { - return func(c *gin.Context) { - ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, orgID.String()) - ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String()) - ctx = context.WithValue(ctx, appcontext.UserRoleKey, role) - c.Request = c.Request.WithContext(ctx) - } - } - for prefix, who := range map[string]struct { - org uuid.UUID - role string - }{"/manager": {org, "manager"}, "/purchasing": {org, "purchasing"}, "/other": {otherOrg, "admin"}} { - g := router.Group(prefix, as(who.org, who.role)) - g.GET("/outlets/:outlet_id/loyalty-settings", h.GetOutletSettings) - g.PUT("/outlets/:outlet_id/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOutletSettings) - } - call := func(method, path string, body string) (int, map[string]any) { - t.Helper() - req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - var out map[string]any - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String()) - return rec.Code, out - } - path := "/outlets/" + outlet.String() + "/loyalty-settings" - data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) } - - // An outlet without settings shows every default and 1% cashback. - status, body := call(http.MethodGet, "/manager"+path, "") - require.Equal(t, http.StatusOK, status, body) - got := data(body) - assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(100), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"]) - assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(25000), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"]) - assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"]) - assert.EqualValues(t, 1, got["point_value"]) - assert.EqualValues(t, 1, got["point_cashback_percent"]) - - // A partial update keeps everything it does not mention. - status, body = call(http.MethodPut, "/manager"+path, `{"point": {"enabled": true, "earn_per_amount": 1000}, "coin": {"max_per_order": 3}}`) - require.Equal(t, http.StatusOK, status, body) - got = data(body) - assert.Equal(t, true, got["point"].(map[string]any)["enabled"]) - assert.EqualValues(t, 1000, got["point"].(map[string]any)["earn_per_amount"]) - assert.EqualValues(t, 1, got["point"].(map[string]any)["earn_value"], "untouched") - assert.EqualValues(t, 25000, got["coin"].(map[string]any)["earn_per_amount"], "untouched") - assert.EqualValues(t, 3, got["coin"].(map[string]any)["max_per_order"]) - assert.EqualValues(t, 0.1, got["point_cashback_percent"], "1 point per Rp 1.000 at Rp 1 a point") - assert.Len(t, got["changes"], 3) - - // The cashback follows the organization's point value. - orgSettings, err := settings.Organization(context.Background(), org) - require.NoError(t, err) - orgSettings.PointValue = 5 - _, _, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings) - require.NoError(t, err) - status, body = call(http.MethodGet, "/manager"+path, "") - require.Equal(t, http.StatusOK, status, body) - assert.EqualValues(t, 0.5, data(body)["point_cashback_percent"]) - - // Sending back what GET returned changes nothing. - echo, err := json.Marshal(data(body)) - require.NoError(t, err) - status, body = call(http.MethodPut, "/manager"+path, string(echo)) - require.Equal(t, http.StatusOK, status, body) - assert.Empty(t, data(body)["changes"]) - - // null clears a limit. - status, body = call(http.MethodPut, "/manager"+path, `{"coin": {"max_per_order": null}}`) - require.Equal(t, http.StatusOK, status, body) - assert.Nil(t, data(body)["coin"].(map[string]any)["max_per_order"]) - - // Values out of bounds, unknown fields and bad JSON are refused and change nothing. - for name, bad := range map[string]string{ - "earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`, - "negative earn_value": `{"coin": {"earn_value": -1}}`, - "negative min_order": `{"point": {"min_order_amount": -5}}`, - "negative max_per_order": `{"point": {"max_per_order": -1}}`, - "payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`, - "unknown field": `{"point": {"earn_per_amout": 50}}`, - "wrong type": `{"point": {"enabled": "yes"}}`, - "not json": `enabled=true`, - } { - status, _ = call(http.MethodPut, "/manager"+path, bad) - assert.Equal(t, http.StatusBadRequest, status, name) - } - status, body = call(http.MethodGet, "/manager"+path, "") - require.Equal(t, http.StatusOK, status, body) - assert.EqualValues(t, 1000, data(body)["point"].(map[string]any)["earn_per_amount"]) - - // Purchasing staff can read but not change; another organization sees nothing. - status, _ = call(http.MethodPut, "/purchasing"+path, `{"point": {"enabled": false}}`) - assert.Equal(t, http.StatusForbidden, status) - status, _ = call(http.MethodGet, "/other"+path, "") - assert.Equal(t, http.StatusNotFound, status) - status, _ = call(http.MethodPut, "/other"+path, `{"point": {"enabled": false}}`) - assert.Equal(t, http.StatusNotFound, status) - status, _ = call(http.MethodGet, "/manager/outlets/not-a-uuid/loyalty-settings", "") - assert.Equal(t, http.StatusBadRequest, status) - - // Every change is in the history with who made it. - history, err := settings.ListChanges(context.Background(), org, &outlet, 1, 100) - require.NoError(t, err) - assert.EqualValues(t, 4, history.Pagination.Total, "three keys, then the limit cleared") - for _, c := range history.Data { - assert.Equal(t, admin, c.ChangedBy) - } -} diff --git a/internal/handler/loyalty_settings_handler.go b/internal/handler/loyalty_settings_handler.go deleted file mode 100644 index ea73cb9..0000000 --- a/internal/handler/loyalty_settings_handler.go +++ /dev/null @@ -1,117 +0,0 @@ -package handler - -import ( - "io" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - - "apskel-pos-be/internal/appcontext" - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" -) - -// loyaltySettingsBodyLimit caps a settings body; a real one is well under 2 KB. -const loyaltySettingsBodyLimit = 64 << 10 - -// LoyaltySettingsHandler serves the loyalty settings (docs/prd-point-coin.md F1, F2). -type LoyaltySettingsHandler struct { - loyaltySettingsService service.LoyaltySettingsService -} - -func NewLoyaltySettingsHandler(loyaltySettingsService service.LoyaltySettingsService) *LoyaltySettingsHandler { - return &LoyaltySettingsHandler{loyaltySettingsService: loyaltySettingsService} -} - -func (h *LoyaltySettingsHandler) GetOutletSettings(c *gin.Context) { - ctx := c.Request.Context() - outletID, ok := parseUUIDParam(c, "outlet_id", "LoyaltySettingsHandler::GetOutletSettings") - if !ok { - return - } - response := h.loyaltySettingsService.GetOutletSettings(ctx, appcontext.FromGinContext(ctx), outletID) - if response.HasErrors() { - logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::GetOutletSettings -> service call failed") - } - util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::GetOutletSettings") -} - -func (h *LoyaltySettingsHandler) UpdateOutletSettings(c *gin.Context) { - ctx := c.Request.Context() - outletID, ok := parseUUIDParam(c, "outlet_id", "LoyaltySettingsHandler::UpdateOutletSettings") - if !ok { - return - } - body, err := io.ReadAll(io.LimitReader(c.Request.Body, loyaltySettingsBodyLimit)) - if err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "unable to read request body"), - }), "LoyaltySettingsHandler::UpdateOutletSettings") - return - } - response := h.loyaltySettingsService.UpdateOutletSettings(ctx, appcontext.FromGinContext(ctx), outletID, body) - if response.HasErrors() { - logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::UpdateOutletSettings -> service call failed") - } - util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::UpdateOutletSettings") -} - -// parseUUIDParam reads a UUID path parameter, answering 400 itself when it is not one. -func parseUUIDParam(c *gin.Context, name, method string) (uuid.UUID, bool) { - id, err := uuid.Parse(c.Param(name)) - if err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid "+name), - }), method) - return uuid.Nil, false - } - return id, true -} - -func (h *LoyaltySettingsHandler) GetOrganizationSettings(c *gin.Context) { - ctx := c.Request.Context() - util.HandleResponse(c.Writer, c.Request, h.loyaltySettingsService.GetOrganizationSettings(ctx, appcontext.FromGinContext(ctx)), "LoyaltySettingsHandler::GetOrganizationSettings") -} - -// UpdateOrganizationSettings is PUT /marketing/loyalty-settings. With ?dry_run=true it -// saves nothing and returns what would change and what the balances in circulation -// would be worth, for the warning the dashboard shows before saving (F2). -func (h *LoyaltySettingsHandler) UpdateOrganizationSettings(c *gin.Context) { - ctx := c.Request.Context() - body, err := io.ReadAll(io.LimitReader(c.Request.Body, loyaltySettingsBodyLimit)) - if err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "unable to read request body"), - }), "LoyaltySettingsHandler::UpdateOrganizationSettings") - return - } - response := h.loyaltySettingsService.UpdateOrganizationSettings(ctx, appcontext.FromGinContext(ctx), body, c.Query("dry_run") == "true") - if response.HasErrors() { - logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::UpdateOrganizationSettings -> service call failed") - } - util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::UpdateOrganizationSettings") -} - -// ListHistory is GET /marketing/loyalty-settings/history, optionally for one outlet -// (?outlet_id=). -func (h *LoyaltySettingsHandler) ListHistory(c *gin.Context) { - ctx := c.Request.Context() - var outletID *uuid.UUID - if raw := c.Query("outlet_id"); raw != "" { - id, err := uuid.Parse(raw) - if err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet_id"), - }), "LoyaltySettingsHandler::ListHistory") - return - } - outletID = &id - } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) - util.HandleResponse(c.Writer, c.Request, h.loyaltySettingsService.ListHistory(ctx, appcontext.FromGinContext(ctx), outletID, page, limit), "LoyaltySettingsHandler::ListHistory") -} diff --git a/internal/handler/loyalty_settings_org_db_test.go b/internal/handler/loyalty_settings_org_db_test.go deleted file mode 100644 index 6b455d5..0000000 --- a/internal/handler/loyalty_settings_org_db_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package handler - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/gin-gonic/gin" - "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/appcontext" - applogger "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/middleware" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" - "apskel-pos-be/internal/service" -) - -// Drives GET/PUT /marketing/loyalty-settings and its history over HTTP down to -// Postgres. Needs TEST_DATABASE_URL pointing at a migrated database; see -// internal/repository/wallet_repository_test.go. -func TestOrganizationLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) { - dsn := os.Getenv("TEST_DATABASE_URL") - if dsn == "" { - t.Skip("TEST_DATABASE_URL not set") - } - applogger.Setup("fatal", "json") - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) - require.NoError(t, err) - - org, admin, customer := uuid.New(), uuid.New(), uuid.New() - 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 (?, 'A', 'basic')`, org) - exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org) - t.Cleanup(func() { - db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer) - db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer) - db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer) - db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org) - db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org) - db.Exec(`DELETE FROM customers WHERE id = ?`, customer) - db.Exec(`DELETE FROM organizations WHERE id = ?`, org) - }) - - // Balances in circulation: 50.000 EnakPoint and 105 EnakCoin. - txm := repository.NewTxManager(db) - wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) - for _, c := range []struct { - currency, ref string - amount int64 - }{{"POINT", "LEGACY_POINTS", 50000}, {"COIN", "LEGACY_TOKENS", 105}} { - require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { - _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ - CustomerID: customer, Currency: c.currency, Type: "MIGRATION", Amount: c.amount, - ReferenceType: c.ref, ReferenceID: uuid.New(), Description: "Saldo awal", - Metadata: map[string]interface{}{"frozen": true}}}) - return err - })) - } - - settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) - h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db))) - auth := middleware.NewAuthMiddleware(nil) - gin.SetMode(gin.TestMode) - router := gin.New() - for prefix, role := range map[string]string{"/manager": "manager", "/purchasing": "purchasing"} { - role := role - g := router.Group(prefix, func(c *gin.Context) { - ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String()) - ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String()) - ctx = context.WithValue(ctx, appcontext.UserRoleKey, role) - c.Request = c.Request.WithContext(ctx) - }) - g.GET("/loyalty-settings", h.GetOrganizationSettings) - g.PUT("/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOrganizationSettings) - g.GET("/loyalty-settings/history", h.ListHistory) - } - call := func(method, path, body string) (int, map[string]any) { - t.Helper() - req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - var out map[string]any - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String()) - return rec.Code, out - } - data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) } - - status, body := call(http.MethodGet, "/manager/loyalty-settings", "") - require.Equal(t, http.StatusOK, status, body) - got := data(body) - assert.EqualValues(t, 1, got["point_value"]) - assert.Equal(t, map[string]any{"coin_amount": float64(1), "point_amount": float64(1)}, got["exchange"]) - assert.Equal(t, true, got["transfer"].(map[string]any)["enabled"]) - assert.Equal(t, "MONTH", got["point_expiry"].(map[string]any)["unit"]) - impact := got["impact"].(map[string]any) - assert.EqualValues(t, 50000, impact["outstanding_points"]) - assert.EqualValues(t, impact["point_rupiah_before"], impact["point_rupiah_after"]) - - // A dry run shows what the balances in circulation would be worth, and saves nothing. - change := `{"point_value": 100, "exchange": {"coin_amount": 10, "point_amount": 1}}` - status, body = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", change) - require.Equal(t, http.StatusOK, status, body) - got = data(body) - assert.Equal(t, true, got["dry_run"]) - impact = got["impact"].(map[string]any) - assert.EqualValues(t, 50000, impact["point_rupiah_before"]) - assert.EqualValues(t, 5000000, impact["point_rupiah_after"]) - assert.EqualValues(t, 105, impact["coins_as_points_before"]) - assert.EqualValues(t, 10, impact["coins_as_points_after"]) - assert.Len(t, got["changes"], 2) - status, body = call(http.MethodGet, "/manager/loyalty-settings", "") - require.Equal(t, http.StatusOK, status, body) - assert.EqualValues(t, 1, data(body)["point_value"], "a dry run saves nothing") - status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "") - require.Equal(t, http.StatusOK, status, body) - assert.EqualValues(t, 0, data(body)["pagination"].(map[string]any)["total_count"]) - - // Saving records every change with who made it. - status, body = call(http.MethodPut, "/manager/loyalty-settings", change) - require.Equal(t, http.StatusOK, status, body) - assert.Equal(t, false, data(body)["dry_run"]) - assert.EqualValues(t, 100, data(body)["point_value"]) - status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "") - require.Equal(t, http.StatusOK, status, body) - history := data(body)["data"].([]any) - require.Len(t, history, 2) - var keys []string - for _, raw := range history { - row := raw.(map[string]any) - keys = append(keys, row["key"].(string)) - assert.Equal(t, admin.String(), row["changed_by"]) - assert.Nil(t, row["old_value"], "was on its default") - assert.Nil(t, row["outlet_id"]) - } - assert.ElementsMatch(t, []string{"loyalty.point.value", "loyalty.exchange.coin_amount"}, keys) - - // Nothing already written changes: balances and ledger rows keep their numbers. - var rows []struct { - Amount int64 - Metadata string - } - require.NoError(t, db.Raw(`SELECT amount, metadata::text AS metadata FROM wallet_transactions WHERE customer_id = ? ORDER BY currency`, customer).Scan(&rows).Error) - require.Len(t, rows, 2) - assert.Equal(t, int64(105), rows[0].Amount) - assert.Equal(t, int64(50000), rows[1].Amount) - for _, r := range rows { - assert.Contains(t, r.Metadata, `"frozen": true`) - } - - // Out of bounds, unknown fields and the wrong role are refused. - for name, bad := range map[string]string{ - "point value 0": `{"point_value": 0}`, - "exchange 0": `{"exchange": {"coin_amount": 0}}`, - "expiry unit YEAR": `{"coin_expiry": {"unit": "YEAR"}}`, - "unknown field": `{"point_valeu": 5}`, - } { - status, _ = call(http.MethodPut, "/manager/loyalty-settings", bad) - assert.Equal(t, http.StatusBadRequest, status, name) - status, _ = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", bad) - assert.Equal(t, http.StatusBadRequest, status, name+" (dry run)") - } - status, _ = call(http.MethodPut, "/purchasing/loyalty-settings", `{"point_value": 5}`) - assert.Equal(t, http.StatusForbidden, status) - status, body = call(http.MethodGet, "/manager/loyalty-settings", "") - require.Equal(t, http.StatusOK, status, body) - assert.EqualValues(t, 100, data(body)["point_value"]) -} diff --git a/internal/handler/order_handler.go b/internal/handler/order_handler.go index df26367..fa3971b 100644 --- a/internal/handler/order_handler.go +++ b/internal/handler/order_handler.go @@ -1,11 +1,8 @@ package handler import ( - "errors" - "apskel-pos-be/internal/appcontext" "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/processor" "apskel-pos-be/internal/service" "apskel-pos-be/internal/transformer" "apskel-pos-be/internal/util" @@ -205,11 +202,6 @@ func (h *OrderHandler) RefundOrder(c *gin.Context) { } if err := h.orderService.RefundOrder(ctx, id, modelReq, userID); err != nil { - // Refusing to hand EnakPoint back as cash is a bad request, not a server fault. - if errors.Is(err, processor.ErrPointPaymentRejected) { - util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::RefundOrder") - return - } util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::RefundOrder", err.Error())}), "OrderHandler::RefundOrder") return } @@ -233,11 +225,6 @@ func (h *OrderHandler) CreatePayment(c *gin.Context) { response, err := h.orderService.CreatePayment(c.Request.Context(), modelReq) if err != nil { - // A refused EnakPoint payment is the cashier's or customer's to fix, not a server fault. - if errors.Is(err, processor.ErrPointPaymentRejected) { - util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::CreatePayment") - return - } util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::CreatePayment", err.Error())}), "OrderHandler::CreatePayment") return } diff --git a/internal/handler/payment_method_handler.go b/internal/handler/payment_method_handler.go index 290d0df..49a914a 100644 --- a/internal/handler/payment_method_handler.go +++ b/internal/handler/payment_method_handler.go @@ -113,16 +113,6 @@ func (h *PaymentMethodHandler) ListPaymentMethods(c *gin.Context) { req.OrganizationID = &contextInfo.OrganizationID - // At the cashier, EnakPoint is listed only where the outlet accepts it (F9). - if outletStr := c.Query("outlet_id"); outletStr != "" { - if outletID, err := uuid.Parse(outletStr); err == nil { - req.OutletID = &outletID - } - } else if contextInfo.OutletID != uuid.Nil { - outletID := contextInfo.OutletID - req.OutletID = &outletID - } - if isActiveStr := c.Query("is_active"); isActiveStr != "" { if isActive, err := strconv.ParseBool(isActiveStr); err == nil { req.IsActive = &isActive diff --git a/internal/handler/point_payment_handler.go b/internal/handler/point_payment_handler.go deleted file mode 100644 index 3c1f3e2..0000000 --- a/internal/handler/point_payment_handler.go +++ /dev/null @@ -1,28 +0,0 @@ -package handler - -import ( - "github.com/gin-gonic/gin" - - "apskel-pos-be/internal/appcontext" - "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" -) - -// PointPaymentHandler serves GET /orders/:id/point-payment/preview -// (docs/prd-point-coin.md F9). -type PointPaymentHandler struct { - pointPaymentService service.PointPaymentService -} - -func NewPointPaymentHandler(pointPaymentService service.PointPaymentService) *PointPaymentHandler { - return &PointPaymentHandler{pointPaymentService: pointPaymentService} -} - -func (h *PointPaymentHandler) Preview(c *gin.Context) { - orderID, ok := parseUUIDParam(c, "id", "PointPaymentHandler::Preview") - if !ok { - return - } - ctx := c.Request.Context() - util.HandleResponse(c.Writer, c.Request, h.pointPaymentService.Preview(ctx, appcontext.FromGinContext(ctx), orderID), "PointPaymentHandler::Preview") -} diff --git a/internal/handler/spin_game_handler.go b/internal/handler/spin_game_handler.go index b7c033d..3b218cb 100644 --- a/internal/handler/spin_game_handler.go +++ b/internal/handler/spin_game_handler.go @@ -1,13 +1,10 @@ package handler import ( - "errors" - "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" - "apskel-pos-be/internal/processor" "apskel-pos-be/internal/service" "apskel-pos-be/internal/util" @@ -52,14 +49,8 @@ func (h *SpinGameHandler) PlaySpinGame(c *gin.Context) { // Play the spin game response, err := h.spinGameService.PlaySpinGame(c.Request.Context(), customerID.(string), modelReq) if err != nil { - // Not enough EnakCoin, an inactive game or a prize that just ran out is the - // customer's to fix, not a server failure. - code := constants.InternalServerErrorCode - if errors.Is(err, processor.ErrGamePlayRefused) { - code = constants.ValidationErrorCode - } util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(code, constants.SpinGameHandlerEntity, err.Error()), + contract.NewResponseError(constants.InternalServerErrorCode, constants.SpinGameHandlerEntity, err.Error()), }), "SpinGameHandler::PlaySpinGame") return } diff --git a/internal/handler/wallet_admin_db_test.go b/internal/handler/wallet_admin_db_test.go deleted file mode 100644 index 7c4de20..0000000 --- a/internal/handler/wallet_admin_db_test.go +++ /dev/null @@ -1,203 +0,0 @@ -package handler - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/gin-gonic/gin" - "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/appcontext" - "apskel-pos-be/internal/constants" - applogger "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" - "apskel-pos-be/internal/service" - "apskel-pos-be/internal/validator" -) - -// Drives the dashboard wallet endpoints over HTTP down to Postgres. Needs -// TEST_DATABASE_URL pointing at a migrated database; see -// internal/repository/wallet_repository_test.go. -func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) { - dsn := os.Getenv("TEST_DATABASE_URL") - if dsn == "" { - t.Skip("TEST_DATABASE_URL not set") - } - applogger.Setup("fatal", "json") - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) - require.NoError(t, err) - - orgA, orgB := uuid.New(), uuid.New() - adminA, adminB := uuid.New(), uuid.New() - customer := uuid.New() - 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 (?, 'A', 'basic'), (?, 'B', 'basic')`, orgA, orgB) - exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES - (?, ?, 'Admin Satu', ?, 'x', 'admin'), (?, ?, 'Admin Lain', ?, 'x', 'admin')`, - adminA, orgA, adminA.String()+"@test", adminB, orgB, adminB.String()+"@test") - exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Budi Santoso', '081234567890')`, customer, orgA) - t.Cleanup(func() { - db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id = ?)`, customer) - db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer) - db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer) - db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer) - db.Exec(`DELETE FROM customers WHERE id = ?`, customer) - db.Exec(`DELETE FROM users WHERE id IN ?`, []uuid.UUID{adminA, adminB}) - db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{orgA, orgB}) - }) - - walletRepo := repository.NewWalletRepository(db) - queryRepo := repository.NewWalletQueryRepository(db) - txm := repository.NewTxManager(db) - wallet := processor.NewWalletProcessor(walletRepo) - require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { - outlet := uuid.New() - _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ - CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn, - Amount: 100, ReferenceType: constants.WalletRefTypeOrder, ReferenceID: uuid.New(), OutletID: &outlet, - Description: "Belanja #ORD-1"}}) - return err - })) - - h := NewWalletAdminHandler( - service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm), txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))), - validator.NewWalletValidator(), - ) - gin.SetMode(gin.TestMode) - router := gin.New() - as := func(org, user uuid.UUID) gin.HandlerFunc { - return func(c *gin.Context) { - ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String()) - ctx = context.WithValue(ctx, appcontext.UserIDKey, user.String()) - c.Request = c.Request.WithContext(ctx) - } - } - for prefix, who := range map[string][2]uuid.UUID{"/a": {orgA, adminA}, "/b": {orgB, adminB}} { - g := router.Group(prefix, as(who[0], who[1])) - g.GET("/customers/:id/wallet", h.GetCustomerWallet) - g.POST("/customers/:id/wallet/adjust", h.AdjustCustomerWallet) - } - call := func(method, path string, body any) (int, map[string]any) { - t.Helper() - var buf bytes.Buffer - if body != nil { - require.NoError(t, json.NewEncoder(&buf).Encode(body)) - } - req := httptest.NewRequest(method, path, &buf) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - var out map[string]any - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String()) - return rec.Code, out - } - adjust := func(prefix string, body map[string]any) (int, map[string]any) { - return call(http.MethodPost, prefix+"/customers/"+customer.String()+"/wallet/adjust", body) - } - spendable := func() float64 { - t.Helper() - status, body := call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet", nil) - require.Equal(t, http.StatusOK, status, body) - return body["data"].(map[string]any)["spendable_point_balance"].(float64) - } - - // Add 50. - status, body := adjust("/a", map[string]any{"currency": "point", "amount": 50, "reason": "komplain #45"}) - require.Equal(t, http.StatusOK, status, body) - tx := body["data"].(map[string]any)["transaction"].(map[string]any) - assert.Equal(t, "ADJUSTMENT", tx["type"]) - assert.EqualValues(t, 50, tx["amount"]) - assert.Equal(t, "komplain #45", tx["reason"]) - assert.Equal(t, "Koreksi oleh admin: komplain #45", tx["description"]) - assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, tx["created_by"]) - assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, tx["source"]) - assert.EqualValues(t, 150, body["data"].(map[string]any)["spendable_point_balance"]) - - // Taking more than the customer can spend is refused and changes nothing. - status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -151, "reason": "salah input"}) - assert.Equal(t, http.StatusBadRequest, status, body) - assert.Equal(t, float64(150), spendable()) - - // Taking away, retried with the same key, happens once. - for i, wantReplayed := range []bool{false, true} { - status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -30, "reason": "salah input", "idempotency_key": "req-1"}) - require.Equal(t, http.StatusOK, status, body) - assert.Equal(t, wantReplayed, body["data"].(map[string]any)["replayed"], "call %d", i+1) - } - assert.Equal(t, float64(120), spendable()) - - // Bad requests. - for name, req := range map[string]map[string]any{ - "cash-out": {"currency": "POINT", "amount": -10, "reason": "pencairan saldo"}, - "zero amount": {"currency": "POINT", "amount": 0, "reason": "x"}, - "no reason": {"currency": "POINT", "amount": 10}, - "bad currency": {"currency": "GOLD", "amount": 10, "reason": "x"}, - } { - status, _ = adjust("/a", req) - assert.Equal(t, http.StatusBadRequest, status, name) - } - status, _ = call(http.MethodPost, "/a/customers/not-a-uuid/wallet/adjust", map[string]any{"currency": "POINT", "amount": 1, "reason": "x"}) - assert.Equal(t, http.StatusBadRequest, status) - assert.Equal(t, float64(120), spendable()) - - // Another organization's admin cannot see or touch this customer. - status, _ = call(http.MethodGet, "/b/customers/"+customer.String()+"/wallet", nil) - assert.Equal(t, http.StatusNotFound, status) - status, _ = adjust("/b", map[string]any{"currency": "POINT", "amount": 1000, "reason": "x"}) - assert.Equal(t, http.StatusNotFound, status) - assert.Equal(t, float64(120), spendable()) - - // The dashboard view. - status, body = call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet?type=ADJUSTMENT", nil) - require.Equal(t, http.StatusOK, status, body) - w := body["data"].(map[string]any) - assert.Equal(t, map[string]any{"id": customer.String(), "name": "Budi Santoso", "phone": "081234567890"}, w["customer"]) - assert.EqualValues(t, 120, w["point_balance"]) - assert.EqualValues(t, 120, w["spendable_point_balance"]) - lots := w["lots"].([]any) - var lotTotal float64 - for _, l := range lots { - lotTotal += l.(map[string]any)["remaining_amount"].(float64) - } - assert.Equal(t, float64(120), lotTotal) - - rows := w["transactions"].(map[string]any)["data"].([]any) - require.Len(t, rows, 2, "the two adjustments, newest first") - newest := rows[0].(map[string]any) - assert.EqualValues(t, -30, newest["amount"]) - assert.Equal(t, "salah input", newest["reason"]) - assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, newest["created_by"]) - assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, newest["destination"]) - - // The customer's own history shows the adjustment too, with the reason in the - // description and without the admin's identity fields. - customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor( - processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), nil))) - crouter := gin.New() - crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions) - rec := httptest.NewRecorder() - crouter.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/wallet/transactions?type=ADJUSTMENT", nil)) - require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) - var cbody map[string]any - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cbody)) - crows := cbody["data"].(map[string]any)["data"].([]any) - require.Len(t, crows, 2) - first := crows[0].(map[string]any) - assert.Equal(t, "Koreksi oleh admin: salah input", first["description"]) - assert.NotContains(t, first, "created_by") - assert.NotContains(t, first, "reason") -} diff --git a/internal/handler/wallet_admin_handler.go b/internal/handler/wallet_admin_handler.go deleted file mode 100644 index 648cbb5..0000000 --- a/internal/handler/wallet_admin_handler.go +++ /dev/null @@ -1,99 +0,0 @@ -package handler - -import ( - "github.com/gin-gonic/gin" - "github.com/google/uuid" - - "apskel-pos-be/internal/appcontext" - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" - "apskel-pos-be/internal/validator" -) - -// WalletAdminHandler serves /marketing/customers/:id/wallet (docs/prd-point-coin.md F7). -type WalletAdminHandler struct { - walletAdminService service.WalletAdminService - walletValidator validator.WalletValidator -} - -func NewWalletAdminHandler(walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator) *WalletAdminHandler { - return &WalletAdminHandler{walletAdminService: walletAdminService, walletValidator: walletValidator} -} - -func (h *WalletAdminHandler) GetCustomerWallet(c *gin.Context) { - ctx := c.Request.Context() - contextInfo := appcontext.FromGinContext(ctx) - - customerID, err := uuid.Parse(c.Param("id")) - if err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid customer ID"), - }), "WalletAdminHandler::GetCustomerWallet") - return - } - - var query models.ListCustomerWalletTransactionsQuery - if err := c.ShouldBindQuery(&query); err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()), - }), "WalletAdminHandler::GetCustomerWallet") - return - } - - response := h.walletAdminService.GetCustomerWallet(ctx, contextInfo, customerID, query) - if response.HasErrors() { - logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::GetCustomerWallet -> service call failed") - } - util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::GetCustomerWallet") -} - -func (h *WalletAdminHandler) AdjustCustomerWallet(c *gin.Context) { - ctx := c.Request.Context() - contextInfo := appcontext.FromGinContext(ctx) - - customerID, err := uuid.Parse(c.Param("id")) - if err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid customer ID"), - }), "WalletAdminHandler::AdjustCustomerWallet") - return - } - - var req contract.AdjustCustomerWalletRequest - if err := c.ShouldBindJSON(&req); err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()), - }), "WalletAdminHandler::AdjustCustomerWallet") - return - } - if validationError, code := h.walletValidator.ValidateAdjustRequest(&req); validationError != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(code, constants.RequestEntity, validationError.Error()), - }), "WalletAdminHandler::AdjustCustomerWallet") - return - } - - response := h.walletAdminService.AdjustCustomerWallet(ctx, contextInfo, customerID, &req) - if response.HasErrors() { - logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::AdjustCustomerWallet -> service call failed") - } - util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::AdjustCustomerWallet") -} - -// TraceTransaction is GET /marketing/wallet-transactions/:id/trace. -func (h *WalletAdminHandler) TraceTransaction(c *gin.Context) { - ctx := c.Request.Context() - transactionID, ok := parseUUIDParam(c, "id", "WalletAdminHandler::TraceTransaction") - if !ok { - return - } - response := h.walletAdminService.TraceTransaction(ctx, appcontext.FromGinContext(ctx), transactionID) - if response.HasErrors() { - logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::TraceTransaction -> service call failed") - } - util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::TraceTransaction") -} diff --git a/internal/mappers/campaign_mapper.go b/internal/mappers/campaign_mapper.go index ffd2940..f662108 100644 --- a/internal/mappers/campaign_mapper.go +++ b/internal/mappers/campaign_mapper.go @@ -71,7 +71,7 @@ func ToCampaignEntity(request *contract.CreateCampaignRequest) *entities.Campaig return &entities.Campaign{ Name: request.Name, Description: request.Description, - Type: entities.NormalizeCampaignType(request.Type), + Type: entities.CampaignType(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.NormalizeCampaignType(request.Type), + Type: entities.CampaignType(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.NormalizeCampaignRewardType(request.RewardType), + RewardType: entities.CampaignRewardType(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.NormalizeCampaignRewardType(request.RewardType), + RewardType: entities.CampaignRewardType(request.RewardType), RewardValue: request.RewardValue, RewardSubtype: rewardSubtype, RewardRefID: request.RewardRefID, diff --git a/internal/mappers/customer_points_mapper.go b/internal/mappers/customer_points_mapper.go new file mode 100644 index 0000000..0cf67ba --- /dev/null +++ b/internal/mappers/customer_points_mapper.go @@ -0,0 +1,46 @@ +package mappers + +import ( + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +// ToCustomerPointsResponse converts a customer points entity to a customer points response +func ToCustomerPointsResponse(customerPoints *entities.CustomerPoints) *models.CustomerPointsResponse { + if customerPoints == nil { + return nil + } + + return &models.CustomerPointsResponse{ + ID: customerPoints.ID, + CustomerID: customerPoints.CustomerID, + Balance: customerPoints.Balance, + Customer: ToCustomerResponse(&customerPoints.Customer), + CreatedAt: customerPoints.CreatedAt, + UpdatedAt: customerPoints.UpdatedAt, + } +} + +// ToCustomerPointsResponses converts a slice of customer points entities to customer points responses +func ToCustomerPointsResponses(customerPoints []entities.CustomerPoints) []models.CustomerPointsResponse { + responses := make([]models.CustomerPointsResponse, len(customerPoints)) + for i, cp := range customerPoints { + responses[i] = *ToCustomerPointsResponse(&cp) + } + return responses +} + +// ToCustomerPointsEntity converts a create customer points request to a customer points entity +func ToCustomerPointsEntity(req *models.CreateCustomerPointsRequest) *entities.CustomerPoints { + return &entities.CustomerPoints{ + CustomerID: req.CustomerID, + Balance: req.Balance, + } +} + +// UpdateCustomerPointsEntity updates a customer points entity with update request data +func UpdateCustomerPointsEntity(customerPoints *entities.CustomerPoints, req *models.UpdateCustomerPointsRequest) { + if req.Balance >= 0 { + customerPoints.Balance = req.Balance + } +} diff --git a/internal/mappers/customer_tokens_mapper.go b/internal/mappers/customer_tokens_mapper.go new file mode 100644 index 0000000..e88cfed --- /dev/null +++ b/internal/mappers/customer_tokens_mapper.go @@ -0,0 +1,48 @@ +package mappers + +import ( + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +// ToCustomerTokensResponse converts a customer tokens entity to a customer tokens response +func ToCustomerTokensResponse(customerTokens *entities.CustomerTokens) *models.CustomerTokensResponse { + if customerTokens == nil { + return nil + } + + return &models.CustomerTokensResponse{ + ID: customerTokens.ID, + CustomerID: customerTokens.CustomerID, + TokenType: string(customerTokens.TokenType), + Balance: customerTokens.Balance, + Customer: ToCustomerResponse(&customerTokens.Customer), + CreatedAt: customerTokens.CreatedAt, + UpdatedAt: customerTokens.UpdatedAt, + } +} + +// ToCustomerTokensResponses converts a slice of customer tokens entities to customer tokens responses +func ToCustomerTokensResponses(customerTokens []entities.CustomerTokens) []models.CustomerTokensResponse { + responses := make([]models.CustomerTokensResponse, len(customerTokens)) + for i, ct := range customerTokens { + responses[i] = *ToCustomerTokensResponse(&ct) + } + return responses +} + +// ToCustomerTokensEntity converts a create customer tokens request to a customer tokens entity +func ToCustomerTokensEntity(req *models.CreateCustomerTokensRequest) *entities.CustomerTokens { + return &entities.CustomerTokens{ + CustomerID: req.CustomerID, + TokenType: entities.TokenType(req.TokenType), + Balance: req.Balance, + } +} + +// UpdateCustomerTokensEntity updates a customer tokens entity with update request data +func UpdateCustomerTokensEntity(customerTokens *entities.CustomerTokens, req *models.UpdateCustomerTokensRequest) { + if req.Balance >= 0 { + customerTokens.Balance = req.Balance + } +} diff --git a/internal/mappers/game_play_mapper.go b/internal/mappers/game_play_mapper.go index 659ca51..cf88268 100644 --- a/internal/mappers/game_play_mapper.go +++ b/internal/mappers/game_play_mapper.go @@ -16,7 +16,7 @@ func ToGamePlayResponse(gamePlay *entities.GamePlay) *models.GamePlayResponse { GameID: gamePlay.GameID, CustomerID: gamePlay.CustomerID, PrizeID: gamePlay.PrizeID, - CoinsUsed: gamePlay.CoinsUsed, + TokenUsed: gamePlay.TokenUsed, RandomSeed: gamePlay.RandomSeed, CreatedAt: gamePlay.CreatedAt, Game: ToGameResponse(&gamePlay.Game), @@ -48,7 +48,7 @@ func ToGamePlayEntity(req *models.CreateGamePlayRequest) *entities.GamePlay { return &entities.GamePlay{ GameID: req.GameID, CustomerID: req.CustomerID, - CoinsUsed: req.CoinsUsed, + TokenUsed: req.TokenUsed, RandomSeed: req.RandomSeed, } } diff --git a/internal/mappers/order_mapper.go b/internal/mappers/order_mapper.go index 8a12404..5f68ce2 100644 --- a/internal/mappers/order_mapper.go +++ b/internal/mappers/order_mapper.go @@ -189,8 +189,6 @@ func PaymentEntityToResponse(payment *entities.Payment) *models.PaymentResponse SplitType: (*string)(payment.SplitType), SplitDescription: payment.SplitDescription, RefundAmount: payment.RefundAmount, - PointsUsed: payment.PointsUsed, - PointValue: payment.PointValue, RefundReason: payment.RefundReason, RefundedAt: payment.RefundedAt, RefundedBy: payment.RefundedBy, diff --git a/internal/mappers/payment_method_mapper.go b/internal/mappers/payment_method_mapper.go index 526acac..651e721 100644 --- a/internal/mappers/payment_method_mapper.go +++ b/internal/mappers/payment_method_mapper.go @@ -134,7 +134,6 @@ func ListPaymentMethodsContractToModel(req *contract.ListPaymentMethodsRequest) return &models.ListPaymentMethodsRequest{ OrganizationID: req.OrganizationID, - OutletID: req.OutletID, Type: paymentMethodType, IsActive: req.IsActive, Search: req.Search, diff --git a/internal/mappers/spin_game_mapper.go b/internal/mappers/spin_game_mapper.go index dd4b184..be5b882 100644 --- a/internal/mappers/spin_game_mapper.go +++ b/internal/mappers/spin_game_mapper.go @@ -38,8 +38,7 @@ func SpinGameResponseDataModelToContract(data *models.SpinGameResponseData) *con return &contract.SpinGameResponseData{ GamePlay: GamePlayResponseModelToContract(&data.GamePlay), PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon), - CoinsRemaining: data.CoinsRemaining, - TokensRemaining: data.CoinsRemaining, + TokensRemaining: data.TokensRemaining, } } @@ -54,8 +53,7 @@ func GamePlayResponseModelToContract(resp *models.GamePlayResponse) contract.Gam GameID: resp.GameID, CustomerID: resp.CustomerID, PrizeID: resp.PrizeID, - CoinsUsed: resp.CoinsUsed, - TokenUsed: resp.CoinsUsed, + TokenUsed: resp.TokenUsed, RandomSeed: resp.RandomSeed, CreatedAt: resp.CreatedAt, Game: nil, // Optional field - can be populated separately if needed diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go index 9bfc7a8..ad42527 100644 --- a/internal/middleware/auth_middleware.go +++ b/internal/middleware/auth_middleware.go @@ -85,14 +85,6 @@ func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc { return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing") } -// RequireLoyaltyManager guards what moves or prices EnakPoint and EnakCoin: loyalty -// settings and manual wallet adjustments (docs/prd-point-coin.md F1, F2, F7). Only -// admins and managers may do these; unlike RequireAdminOrManager it does not let the -// purchasing role through. -func (m *AuthMiddleware) RequireLoyaltyManager() gin.HandlerFunc { - return m.RequireRole("superadmin", "admin", "manager", "owner") -} - func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc { return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing") } diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 141dac2..109a65b 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -33,14 +33,7 @@ type PaymentMethodAnalyticsResponse struct { // PaymentMethodSummary represents the summary of payment method analytics type PaymentMethodSummary struct { - // Money actually received. EnakPoint is not money in (docs/prd-point-coin.md F9, - // K7) and is reported apart; its accounting treatment waits on note N2. - TotalAmount float64 `json:"total_amount"` - // Rupiah paid with EnakPoint, and how many EnakPoint that was. - PointAmount float64 `json:"point_amount"` - PointsUsed int64 `json:"points_used"` - // TotalAmount plus PointAmount: the value of the orders paid. - TotalWithPoints float64 `json:"total_with_points"` + TotalAmount float64 `json:"total_amount"` TotalOrders int64 `json:"total_orders"` TotalPayments int64 `json:"total_payments"` AverageOrderValue float64 `json:"average_order_value"` @@ -54,11 +47,7 @@ type PaymentMethodAnalyticsData struct { TotalAmount float64 `json:"total_amount"` OrderCount int64 `json:"order_count"` PaymentCount int64 `json:"payment_count"` - // Share of the money received; 0 for EnakPoint, which is not money in. - Percentage float64 `json:"percentage"` - PointsUsed int64 `json:"points_used"` - // False for EnakPoint. - CountsAsCashIn bool `json:"counts_as_cash_in"` + Percentage float64 `json:"percentage"` } // SalesAnalyticsRequest represents the request for sales analytics @@ -475,12 +464,12 @@ type ProfitLossAnalyticsResponse struct { } type ProfitLossPurchasing struct { - TodayTotal float64 `json:"today_total"` - MtdTotal float64 `json:"mtd_total"` - TodayRawMaterial float64 `json:"today_raw_material"` - MtdRawMaterial float64 `json:"mtd_raw_material"` - TodayExpense float64 `json:"today_expense"` - MtdExpense float64 `json:"mtd_expense"` + TodayTotal float64 `json:"today_total"` + MtdTotal float64 `json:"mtd_total"` + TodayRawMaterial float64 `json:"today_raw_material"` + MtdRawMaterial float64 `json:"mtd_raw_material"` + TodayExpense float64 `json:"today_expense"` + MtdExpense float64 `json:"mtd_expense"` Items []ProfitLossPurchasingItem `json:"items"` } diff --git a/internal/models/campaign.go b/internal/models/campaign.go index 9720694..9c65dc6 100644 --- a/internal/models/campaign.go +++ b/internal/models/campaign.go @@ -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 COINS TOKENS MIXED"` + Type string `json:"type" binding:"required,oneof=REWARD POINTS 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 COINS TOKENS MIXED"` + Type string `json:"type" binding:"required,oneof=REWARD POINTS 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 COINS TOKENS REWARD"` + RewardType string `json:"reward_type" binding:"required,oneof=POINTS 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 COINS TOKENS REWARD"` + RewardType string `json:"reward_type" binding:"required,oneof=POINTS 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 COINS TOKENS REWARD"` + RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"` RewardValue *int64 `json:"reward_value,omitempty"` RewardSubtype *string `json:"reward_subtype,omitempty"` RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` diff --git a/internal/models/customer_pin.go b/internal/models/customer_pin.go deleted file mode 100644 index 6b3616c..0000000 --- a/internal/models/customer_pin.go +++ /dev/null @@ -1,47 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -// CustomerPinStatus is GET /customer/pin/status. -type CustomerPinStatus struct { - HasPin bool `json:"has_pin"` - LockedUntil *time.Time `json:"locked_until"` - TransferBlockedUntil *time.Time `json:"transfer_blocked_until"` -} - -// CustomerPinOtp is what POST /customer/pin/otp returns: the token to send back with -// the code the customer received. -type CustomerPinOtp struct { - Purpose string `json:"purpose"` - OtpToken string `json:"otp_token"` - ExpiresAt time.Time `json:"expires_at"` -} - -// CustomerSecurityEventView is one row of GET /marketing/customers/:id/security-events. -type CustomerSecurityEventView struct { - ID uuid.UUID `json:"id"` - Event string `json:"event"` - ActorUser *uuid.UUID `json:"actor_user,omitempty"` - Reason *string `json:"reason,omitempty"` - IPAddress *string `json:"ip_address,omitempty"` - UserAgent *string `json:"user_agent,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// CustomerPinRequestInfo is where a PIN request came from, for the security log. -type CustomerPinRequestInfo struct { - IPAddress string - UserAgent string -} - -// PaymentCode is what POST /customer/wallet/payment-code returns: a one-time code the -// customer shows the cashier, as digits or as a QR of QRPayload. -type PaymentCode struct { - Code string `json:"code"` - QRPayload string `json:"qr_payload"` - ExpiresAt time.Time `json:"expires_at"` -} diff --git a/internal/models/customer_points.go b/internal/models/customer_points.go index 9e1c84f..cfe9b45 100644 --- a/internal/models/customer_points.go +++ b/internal/models/customer_points.go @@ -2,8 +2,66 @@ package models import ( "time" + + "github.com/google/uuid" ) +// Existing gamification models +type CreateCustomerPointsRequest struct { + CustomerID uuid.UUID `json:"customer_id" validate:"required"` + Balance int64 `json:"balance" validate:"min=0"` +} + +type UpdateCustomerPointsRequest struct { + Balance int64 `json:"balance" validate:"min=0"` +} + +type AddCustomerPointsRequest struct { + Balance int64 `json:"balance" validate:"required,min=1"` +} + +type DeductCustomerPointsRequest struct { + Balance int64 `json:"balance" validate:"required,min=1"` +} + +type CustomerPointsResponse struct { + ID uuid.UUID `json:"id"` + CustomerID uuid.UUID `json:"customer_id"` + Balance int64 `json:"balance"` + Customer *CustomerResponse `json:"customer,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListCustomerPointsQuery struct { + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Search string `json:"search"` + SortBy string `json:"sort_by" validate:"omitempty,oneof=balance created_at updated_at"` + SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"` +} + +type PaginatedCustomerPointsResponse struct { + Data []CustomerPointsResponse `json:"data"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} + +// New customer API models +type GetCustomerPointsRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + +type GetCustomerTokensRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + +type GetCustomerWalletRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + // Response Models type GetCustomerPointsResponse struct { Status string `json:"status"` @@ -51,20 +109,7 @@ type GetCustomerWalletResponse struct { Data *GetCustomerWalletResponseData `json:"data,omitempty"` } -// GetCustomerWalletResponseData is GET /customer/wallet (docs/prd-point-coin.md F6). type GetCustomerWalletResponseData struct { - // Balances the customer can spend now. - PointBalance int64 `json:"point_balance"` - CoinBalance int64 `json:"coin_balance"` - // Rupiah value of one EnakPoint, and of the whole point balance. EnakPoint only - // ever pays for orders, so the app shows this as "setara potongan Rp …" (K7). - PointValue int64 `json:"point_value"` - PointDiscountValue int64 `json:"point_discount_value"` - NearestExpiring CustomerWalletNearestExpiring `json:"nearest_expiring"` - RecentTransactions []CustomerWalletTransaction `json:"recent_transactions"` - - // Deprecated: the shape of this endpoint before the wallet, kept for app versions - // that still read it. Filled from the wallet like the fields above. TotalPoints int64 `json:"total_points"` TotalTokens int64 `json:"total_tokens"` PointsHistory []PointsHistoryItem `json:"points_history,omitempty"` diff --git a/internal/models/customer_tokens.go b/internal/models/customer_tokens.go new file mode 100644 index 0000000..7ee4455 --- /dev/null +++ b/internal/models/customer_tokens.go @@ -0,0 +1,44 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type CreateCustomerTokensRequest struct { + CustomerID uuid.UUID `json:"customer_id" validate:"required"` + TokenType string `json:"token_type" validate:"required,oneof=SPIN RAFFLE MINIGAME"` + Balance int64 `json:"balance" validate:"min=0"` +} + +type UpdateCustomerTokensRequest struct { + Balance int64 `json:"balance" validate:"min=0"` +} + +type AddCustomerTokensRequest struct { + Tokens int64 `json:"tokens" validate:"required,min=1"` +} + +type DeductCustomerTokensRequest struct { + Tokens int64 `json:"tokens" validate:"required,min=1"` +} + +type CustomerTokensResponse struct { + ID uuid.UUID `json:"id"` + CustomerID uuid.UUID `json:"customer_id"` + TokenType string `json:"token_type"` + Balance int64 `json:"balance"` + Customer *CustomerResponse `json:"customer,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListCustomerTokensQuery struct { + Page int `query:"page" validate:"min=1"` + Limit int `query:"limit" validate:"min=1,max=100"` + Search string `query:"search"` + TokenType string `query:"token_type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=balance token_type created_at updated_at"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` +} diff --git a/internal/models/game_play.go b/internal/models/game_play.go index f2378b3..342c2c9 100644 --- a/internal/models/game_play.go +++ b/internal/models/game_play.go @@ -9,7 +9,7 @@ import ( type CreateGamePlayRequest struct { GameID uuid.UUID `json:"game_id" validate:"required"` CustomerID uuid.UUID `json:"customer_id" validate:"required"` - CoinsUsed int `json:"coins_used" validate:"min=0"` + TokenUsed int `json:"token_used" validate:"min=0"` RandomSeed *string `json:"random_seed,omitempty"` } @@ -18,7 +18,7 @@ type GamePlayResponse struct { GameID uuid.UUID `json:"game_id"` CustomerID uuid.UUID `json:"customer_id"` PrizeID *uuid.UUID `json:"prize_id,omitempty"` - CoinsUsed int `json:"coins_used"` + TokenUsed int `json:"token_used"` RandomSeed *string `json:"random_seed,omitempty"` CreatedAt time.Time `json:"created_at"` Game *GameResponse `json:"game,omitempty"` @@ -33,18 +33,18 @@ type ListGamePlaysQuery struct { GameID *uuid.UUID `query:"game_id"` CustomerID *uuid.UUID `query:"customer_id"` PrizeID *uuid.UUID `query:"prize_id"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at token_used"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` } -// PlayGameRequest plays a game for a customer, at the game's metadata.coin_cost. type PlayGameRequest struct { GameID uuid.UUID `json:"game_id" validate:"required"` CustomerID uuid.UUID `json:"customer_id" validate:"required"` + TokenUsed int `json:"token_used" validate:"min=0"` } type PlayGameResponse struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` - CoinsRemaining int64 `json:"coins_remaining"` + GamePlay GamePlayResponse `json:"game_play"` + PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` + TokensRemaining int64 `json:"tokens_remaining"` } diff --git a/internal/models/loyalty.go b/internal/models/loyalty.go deleted file mode 100644 index 3a0f8b1..0000000 --- a/internal/models/loyalty.go +++ /dev/null @@ -1,193 +0,0 @@ -package models - -import ( - "math" - "time" - - "github.com/google/uuid" -) - -// OutletLoyaltySettings are an outlet's loyalty settings (docs/prd-point-coin.md F1). -type OutletLoyaltySettings struct { - Point LoyaltyEarnSettings `json:"point"` - Coin LoyaltyEarnSettings `json:"coin"` - // Paying with EnakPoint. EnakCoin cannot pay, so it has no counterpart. - PointPayment LoyaltyPointPaymentSettings `json:"point_payment"` -} - -// LoyaltyEarnSettings is how much of one currency an order earns: -// floor(basis / EarnPerAmount) × EarnValue, nothing below MinOrderAmount, and at most -// MaxPerOrder when set. -type LoyaltyEarnSettings struct { - Enabled bool `json:"enabled"` - EarnPerAmount int64 `json:"earn_per_amount"` - EarnValue int64 `json:"earn_value"` - MinOrderAmount int64 `json:"min_order_amount"` - MaxPerOrder *int64 `json:"max_per_order"` -} - -type LoyaltyPointPaymentSettings struct { - AcceptPayment bool `json:"accept_payment"` - MinPaymentPoints int64 `json:"min_payment_points"` - // Largest share of the order total, 0–100, that EnakPoint may pay. - MaxPaymentPercent int64 `json:"max_payment_percent"` -} - -// OrganizationLoyaltySettings are the loyalty settings shared by every outlet of an -// organization (docs/prd-point-coin.md F2, F12). -type OrganizationLoyaltySettings struct { - // Rupiah value of one EnakPoint when paying. - PointValue int64 `json:"point_value"` - // CoinAmount EnakCoin exchange into PointAmount EnakPoint. - Exchange LoyaltyExchangeSettings `json:"exchange"` - Transfer LoyaltyTransferSettings `json:"transfer"` - PointExpiry LoyaltyExpirySettings `json:"point_expiry"` - CoinExpiry LoyaltyExpirySettings `json:"coin_expiry"` -} - -type LoyaltyExchangeSettings struct { - CoinAmount int64 `json:"coin_amount"` - PointAmount int64 `json:"point_amount"` -} - -type LoyaltyTransferSettings struct { - Enabled bool `json:"enabled"` - MinAmount int64 `json:"min_amount"` - MaxPerTransaction *int64 `json:"max_per_transaction"` - DailyLimit *int64 `json:"daily_limit"` -} - -// LoyaltyExpirySettings is when one currency expires once received (F12). Both -// models of note N4 are supported, and the owner picks one: -// -// - FIXED_DATE: everything expires on the next of FixedDates falling on or after -// the day received + GraceMonths, so a balance received just before a date moves -// on to the one after. -// - ROLLING: everything lasts Period Units from the day received, to the end of -// that month when EndOfMonth is set. -type LoyaltyExpirySettings struct { - Enabled bool `json:"enabled"` - // FIXED_DATE or ROLLING. - Mode string `json:"mode"` - // FIXED_DATE: the days of the year balances expire on, as MM-DD, sorted. - FixedDates []string `json:"fixed_dates"` - // FIXED_DATE: how many months a balance lasts at least before a fixed date takes it. - GraceMonths int64 `json:"grace_months"` - // ROLLING: how long a balance lasts. - Period int64 `json:"period"` - // ROLLING: DAY or MONTH. - Unit string `json:"unit"` - EndOfMonth bool `json:"end_of_month"` - // Days before expiry the customer is reminded; 0 for no reminder. - ReminderDays int64 `json:"reminder_days"` -} - -// LoyaltySettingChange is one row of the loyalty settings history. -type LoyaltySettingChange struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id"` - Key string `json:"key"` - // Nil when the key had no stored value, that is it was on its default. - OldValue *string `json:"old_value"` - NewValue *string `json:"new_value"` - ChangedBy uuid.UUID `json:"changed_by"` - CreatedAt time.Time `json:"created_at"` -} - -// OutletLoyaltySettingsView is GET and PUT /outlets/:id/loyalty-settings. -type OutletLoyaltySettingsView struct { - OutletID uuid.UUID `json:"outlet_id"` - OutletLoyaltySettings - // The organization's rupiah value of one EnakPoint, which the cashback depends on. - PointValue int64 `json:"point_value"` - // Effective EnakPoint cashback in percent: earn_value × point_value / - // earn_per_amount × 100. Shown next to the setting so an owner cannot misread the - // scale (F1). - PointCashbackPercent float64 `json:"point_cashback_percent"` - // Set on PUT: the keys that changed. - Changes []LoyaltySettingChange `json:"changes,omitempty"` -} - -// LoyaltyCashbackPercent is earnValue × pointValue / earnPerAmount as a percentage, -// rounded to two decimals. -func LoyaltyCashbackPercent(earnValue, pointValue, earnPerAmount int64) float64 { - if earnPerAmount <= 0 { - return 0 - } - return math.Round(float64(earnValue)*float64(pointValue)*10000/float64(earnPerAmount)) / 100 -} - -// OrganizationLoyaltySettingsView is GET and PUT /marketing/loyalty-settings. -type OrganizationLoyaltySettingsView struct { - OrganizationLoyaltySettings - // What the balances in circulation are worth, before and after the change. - Impact LoyaltySettingsImpact `json:"impact"` - // When a balance received now would expire under these settings (F12). - ExpiryPreview LoyaltyExpiryPreview `json:"expiry_preview"` - // The currencies this change turns expiry on for, and the balances affected. - ExpiryActivations []LoyaltyExpiryActivation `json:"expiry_activations"` - // On PUT, the keys that changed; on a dry run, the keys that would. - Changes []LoyaltySettingChange `json:"changes"` - // True when nothing was saved. - DryRun bool `json:"dry_run"` -} - -// LoyaltyExpiryActivation is expiry being turned on for a currency: the balances that -// had no expiry and the expiry they get (F12). On a dry run nothing is dated yet. -type LoyaltyExpiryActivation struct { - Currency string `json:"currency"` - Lots int64 `json:"lots"` - Amount int64 `json:"amount"` - ExpiresAt time.Time `json:"expires_at"` -} - -// LoyaltyExpiryPreview is what the dashboard shows next to the expiry settings: "the -// EnakPoint received today expire on …". Nil means they never expire. -type LoyaltyExpiryPreview struct { - Point *time.Time `json:"point"` - Coin *time.Time `json:"coin"` -} - -// LoyaltySettingsImpact shows how a change of point value or exchange rate changes what -// the balances in circulation are worth (F2). Before and after are equal when neither -// changes. -type LoyaltySettingsImpact struct { - OutstandingPoints int64 `json:"outstanding_points"` - OutstandingCoins int64 `json:"outstanding_coins"` - - PointValueBefore int64 `json:"point_value_before"` - PointValueAfter int64 `json:"point_value_after"` - PointRupiahBefore int64 `json:"point_rupiah_before"` - PointRupiahAfter int64 `json:"point_rupiah_after"` - - // The coins in circulation exchanged at the rate, in EnakPoint and in rupiah. - CoinsAsPointsBefore int64 `json:"coins_as_points_before"` - CoinsAsPointsAfter int64 `json:"coins_as_points_after"` - CoinRupiahBefore int64 `json:"coin_rupiah_before"` - CoinRupiahAfter int64 `json:"coin_rupiah_after"` -} - -// NewLoyaltySettingsImpact computes the impact of moving from one organization setting -// to another on the balances in circulation. -func NewLoyaltySettingsImpact(points, coins int64, before, after OrganizationLoyaltySettings) LoyaltySettingsImpact { - asPoints := func(s OrganizationLoyaltySettings) int64 { - if s.Exchange.CoinAmount <= 0 { - return 0 - } - return coins * s.Exchange.PointAmount / s.Exchange.CoinAmount - } - impact := LoyaltySettingsImpact{ - OutstandingPoints: points, - OutstandingCoins: coins, - PointValueBefore: before.PointValue, - PointValueAfter: after.PointValue, - PointRupiahBefore: points * before.PointValue, - PointRupiahAfter: points * after.PointValue, - CoinsAsPointsBefore: asPoints(before), - CoinsAsPointsAfter: asPoints(after), - } - impact.CoinRupiahBefore = impact.CoinsAsPointsBefore * before.PointValue - impact.CoinRupiahAfter = impact.CoinsAsPointsAfter * after.PointValue - return impact -} diff --git a/internal/models/loyalty_test.go b/internal/models/loyalty_test.go deleted file mode 100644 index 2fc48e5..0000000 --- a/internal/models/loyalty_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package models - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestLoyaltyCashbackPercent(t *testing.T) { - assert.Equal(t, 1.0, LoyaltyCashbackPercent(1, 1, 100)) - assert.Equal(t, 0.1, LoyaltyCashbackPercent(1, 1, 1000)) - assert.Equal(t, 0.33, LoyaltyCashbackPercent(1, 1, 300), "rounded to two decimals") - assert.Equal(t, 0.67, LoyaltyCashbackPercent(2, 1, 300), "rounded, not cut") - assert.Equal(t, 0.0, LoyaltyCashbackPercent(1, 1, 0)) -} - -func TestNewLoyaltySettingsImpact(t *testing.T) { - before := OrganizationLoyaltySettings{PointValue: 1, Exchange: LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}} - after := OrganizationLoyaltySettings{PointValue: 100, Exchange: LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}} - - got := NewLoyaltySettingsImpact(50000, 105, before, after) - assert.Equal(t, LoyaltySettingsImpact{ - OutstandingPoints: 50000, OutstandingCoins: 105, - PointValueBefore: 1, PointValueAfter: 100, - PointRupiahBefore: 50000, PointRupiahAfter: 5000000, - CoinsAsPointsBefore: 105, CoinsAsPointsAfter: 10, // floor(105 / 10) - CoinRupiahBefore: 105, CoinRupiahAfter: 1000, - }, got) - - same := NewLoyaltySettingsImpact(10, 10, before, before) - assert.Equal(t, same.PointRupiahBefore, same.PointRupiahAfter) -} diff --git a/internal/models/order.go b/internal/models/order.go index d15278f..d18e2a9 100644 --- a/internal/models/order.go +++ b/internal/models/order.go @@ -182,9 +182,6 @@ type OrderResponse struct { TotalPaid float64 PaymentCount int SplitType *string - // What the order earned (docs/prd-point-coin.md F3), from its EARN rows. - PointsEarned int64 - CoinsEarned int64 } type OrderItemResponse struct { diff --git a/internal/models/payment.go b/internal/models/payment.go index 1269b9e..bf35894 100644 --- a/internal/models/payment.go +++ b/internal/models/payment.go @@ -28,10 +28,8 @@ type Payment struct { } type CreatePaymentRequest struct { - OrderID uuid.UUID `validate:"required"` - PaymentMethodID uuid.UUID `validate:"required"` - Points *int64 - PaymentCode *string + OrderID uuid.UUID `validate:"required"` + PaymentMethodID uuid.UUID `validate:"required"` Amount float64 `validate:"required,min=0"` TransactionID *string `validate:"omitempty"` SplitNumber int `validate:"omitempty,min=1"` @@ -62,9 +60,6 @@ type PaymentResponse struct { SplitType *string SplitDescription *string RefundAmount float64 - // Set for a payment with EnakPoint. - PointsUsed *int64 - PointValue *float64 RefundReason *string RefundedAt *time.Time RefundedBy *uuid.UUID diff --git a/internal/models/payment_method.go b/internal/models/payment_method.go index b7ea272..398586d 100644 --- a/internal/models/payment_method.go +++ b/internal/models/payment_method.go @@ -51,13 +51,11 @@ type PaymentMethodResponse struct { type ListPaymentMethodsRequest struct { OrganizationID *uuid.UUID - // When set, EnakPoint is left out unless the outlet accepts it (F9). - OutletID *uuid.UUID - Type *constants.PaymentMethodType - IsActive *bool - Search string - Page int `validate:"min=1"` - Limit int `validate:"min=1,max=100"` + Type *constants.PaymentMethodType + IsActive *bool + Search string + Page int `validate:"min=1"` + Limit int `validate:"min=1,max=100"` } type ListPaymentMethodsResponse struct { diff --git a/internal/models/spin_game.go b/internal/models/spin_game.go index a384b7a..ae66946 100644 --- a/internal/models/spin_game.go +++ b/internal/models/spin_game.go @@ -20,9 +20,9 @@ type SpinGameResponse struct { // SpinGameResponseData contains the game play result type SpinGameResponseData struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` - CoinsRemaining int64 `json:"coins_remaining"` + GamePlay GamePlayResponse `json:"game_play"` + PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` + TokensRemaining int64 `json:"tokens_remaining"` } // SpinGamePrizeResponse represents a prize won in the spin game diff --git a/internal/models/wallet.go b/internal/models/wallet.go deleted file mode 100644 index 9412444..0000000 --- a/internal/models/wallet.go +++ /dev/null @@ -1,166 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -// CustomerWalletTransaction is one ledger row as the customer app shows it -// (docs/prd-point-coin.md F6). -type CustomerWalletTransaction struct { - ID uuid.UUID `json:"id"` - Currency string `json:"currency"` - Type string `json:"type"` - // Signed: positive added to the balance, negative taken from it. - Amount int64 `json:"amount"` - BalanceAfter int64 `json:"balance_after"` - Description string `json:"description"` - // Where the value came from, set on additions. - Source *CustomerWalletTransactionRef `json:"source,omitempty"` - // Where the value went, set on deductions. - Destination *CustomerWalletTransactionRef `json:"destination,omitempty"` - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - ReversesTransactionID *uuid.UUID `json:"reverses_transaction_id,omitempty"` - // Shared by the two rows of an exchange or a transfer. - GroupID *uuid.UUID `json:"group_id,omitempty"` - // Additions only: the earliest expiry among the lots it created, nil when none of - // them expire, and the lots themselves. - ExpiresAt *time.Time `json:"expires_at,omitempty"` - Lots []CustomerWalletLot `json:"lots,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// CustomerWalletTransactionRef points at what a ledger row came from or went to, as -// listed in §8.1: ORDER, PAYMENT, WALLET_TX, GAME_PLAY, LOT, USER and so on. -type CustomerWalletTransactionRef struct { - Type string `json:"type"` - ID uuid.UUID `json:"id"` -} - -type CustomerWalletLot struct { - Amount int64 `json:"amount"` - Remaining int64 `json:"remaining"` - ExpiresAt *time.Time `json:"expires_at"` -} - -// CustomerWalletExpiring is how much expires on one day. -type CustomerWalletExpiring struct { - Amount int64 `json:"amount"` - // YYYY-MM-DD, Asia/Jakarta. - Date string `json:"date"` -} - -// CustomerWalletNearestExpiring is the next day each currency loses balance, nil when -// nothing is due to expire. -type CustomerWalletNearestExpiring struct { - Point *CustomerWalletExpiring `json:"point"` - Coin *CustomerWalletExpiring `json:"coin"` -} - -// ListCustomerWalletTransactionsQuery is GET /customer/wallet/transactions. -type ListCustomerWalletTransactionsQuery struct { - Page int `form:"page"` - Limit int `form:"limit"` - Currency string `form:"currency"` - // One type, or several separated by commas. - Type string `form:"type"` - // Inclusive calendar dates, YYYY-MM-DD, Asia/Jakarta. - From string `form:"from"` - To string `form:"to"` -} - -// AdminCustomerWallet is GET /marketing/customers/:id/wallet (docs/prd-point-coin.md -// F7). Unlike the customer's own view it shows the raw balances next to the spendable -// ones, every lot that still holds something, and the real names behind each row. -type AdminCustomerWallet struct { - Customer AdminWalletCustomer `json:"customer"` - // Balances as the ledger has them. - PointBalance int64 `json:"point_balance"` - CoinBalance int64 `json:"coin_balance"` - // What can be spent now. Lower than the ledger balance only while lots that have - // expired wait for the expiry job. - SpendablePointBalance int64 `json:"spendable_point_balance"` - SpendableCoinBalance int64 `json:"spendable_coin_balance"` - Lots []AdminWalletLot `json:"lots"` - Transactions PaginatedResponse[AdminWalletTransaction] `json:"transactions"` -} - -type AdminWalletCustomer struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Phone *string `json:"phone,omitempty"` -} - -type AdminWalletLot struct { - ID uuid.UUID `json:"id"` - Currency string `json:"currency"` - OriginalAmount int64 `json:"original_amount"` - RemainingAmount int64 `json:"remaining_amount"` - ExpiresAt *time.Time `json:"expires_at"` - Expired bool `json:"expired"` - SourceTransactionID uuid.UUID `json:"source_transaction_id"` - OriginLotID *uuid.UUID `json:"origin_lot_id,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// AdminWalletTransaction is a ledger row with the names the customer does not see: -// the real counterparty of a transfer, the admin behind an adjustment, the cashier who -// took a payment, and the outlet. -type AdminWalletTransaction struct { - CustomerWalletTransaction - Counterparty *AdminWalletNamedRef `json:"counterparty,omitempty"` - CreatedBy *AdminWalletNamedRef `json:"created_by,omitempty"` - Outlet *AdminWalletNamedRef `json:"outlet,omitempty"` - Reason *string `json:"reason,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` -} - -type AdminWalletNamedRef struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` -} - -// WalletAdjustment is a manual correction by an admin. -type WalletAdjustment struct { - Currency string - // Signed: positive adds, negative takes away. - Amount int64 - Reason string - IdempotencyKey string -} - -// AdminWalletAdjustmentResult is what POST /marketing/customers/:id/wallet/adjust returns. -type AdminWalletAdjustmentResult struct { - Transaction AdminWalletTransaction `json:"transaction"` - SpendablePointBalance int64 `json:"spendable_point_balance"` - SpendableCoinBalance int64 `json:"spendable_coin_balance"` - // True when the idempotency key had been used before and nothing changed. - Replayed bool `json:"replayed"` -} - -// PointPaymentPreview is GET /orders/:id/point-payment/preview (docs/prd-point-coin.md -// F9): whether the order can be paid with EnakPoint and at most how much, for the -// cashier's "use maximum" button. -type PointPaymentPreview struct { - OrderID uuid.UUID `json:"order_id"` - CustomerID *uuid.UUID `json:"customer_id"` - Eligible bool `json:"eligible"` - // Why not, when not eligible. - Reason string `json:"reason,omitempty"` - PointBalance int64 `json:"point_balance"` - PointValue int64 `json:"point_value"` - RemainingAmount float64 `json:"remaining_amount"` - MinPaymentPoints int64 `json:"min_payment_points"` - MaxPaymentPercent int64 `json:"max_payment_percent"` - MaxPoints int64 `json:"max_points"` - // Rupiah covered by MaxPoints. - MaxAmount int64 `json:"max_amount"` -} - -// CustomerWalletExpiringList is GET /customer/wallet/expiring (docs/prd-point-coin.md -// F6): everything that will expire, per currency and day, soonest first. -type CustomerWalletExpiringList struct { - Point []CustomerWalletExpiring `json:"point"` - Coin []CustomerWalletExpiring `json:"coin"` -} diff --git a/internal/models/wallet_move.go b/internal/models/wallet_move.go deleted file mode 100644 index d348cf4..0000000 --- a/internal/models/wallet_move.go +++ /dev/null @@ -1,73 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -// WalletExchangePreview is GET /customer/wallet/exchange/preview -// (docs/prd-point-coin.md F4): the rate, and what exchanging Coins would give. -type WalletExchangePreview struct { - // The rate: CoinAmount EnakCoin exchange into PointAmount EnakPoint. - CoinAmount int64 `json:"coin_amount"` - PointAmount int64 `json:"point_amount"` - CoinBalance int64 `json:"coin_balance"` - Coins int64 `json:"coins"` - Points int64 `json:"points"` - // Whether Coins can be exchanged now, and why not when it cannot. - Valid bool `json:"valid"` - Reason string `json:"reason,omitempty"` -} - -// WalletMovedLot is part of what an exchange or a transfer delivered, with the -// expiry it carried over from the lot it came from (K9). -type WalletMovedLot struct { - Amount int64 `json:"amount"` - // Nil when it never expires. - ExpiresAt *time.Time `json:"expires_at"` -} - -// WalletExchangeResult is POST /customer/wallet/exchange. -type WalletExchangeResult struct { - GroupID uuid.UUID `json:"group_id"` - Coins int64 `json:"coins"` - Points int64 `json:"points"` - CoinAmount int64 `json:"coin_amount"` - PointAmount int64 `json:"point_amount"` - // The EnakPoint received, split by expiry. - Lots []WalletMovedLot `json:"lots"` - CoinBalance int64 `json:"coin_balance"` - PointBalance int64 `json:"point_balance"` - // True when this was a retry of an exchange already made; nothing moved again. - Replayed bool `json:"replayed"` -} - -// WalletTransferRecipient is GET /customer/wallet/transfer/recipient: who a phone -// number belongs to, masked, so the sender can check before confirming (F5). -type WalletTransferRecipient struct { - Name string `json:"name"` - PhoneNumber string `json:"phone_number"` -} - -// WalletTransfer is what a customer asks to send (F5). -type WalletTransfer struct { - // POINT or COIN. - Currency string - Amount int64 - RecipientPhone string -} - -// WalletTransferResult is POST /customer/wallet/transfer. -type WalletTransferResult struct { - GroupID uuid.UUID `json:"group_id"` - Currency string `json:"currency"` - Amount int64 `json:"amount"` - Recipient WalletTransferRecipient `json:"recipient"` - // What the recipient received, split by the expiry it carried over. - Lots []WalletMovedLot `json:"lots"` - // The sender's balance in the currency sent. - Balance int64 `json:"balance"` - // True when this was a retry of a transfer already made; nothing moved again. - Replayed bool `json:"replayed"` -} diff --git a/internal/models/wallet_trace.go b/internal/models/wallet_trace.go deleted file mode 100644 index 809d7c9..0000000 --- a/internal/models/wallet_trace.go +++ /dev/null @@ -1,46 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -// AdminWalletTrace is GET /marketing/wallet-transactions/:id/trace -// (docs/prd-point-coin.md F7, §8.1): the lots a ledger row took from or created, and -// where each of them came from, lot by lot, back to the lot that first brought the -// balance into a wallet. It follows balance across transfers, exchanges and refunds. -type AdminWalletTrace struct { - Transaction AdminWalletTraceRow `json:"transaction"` - // One per lot the row took from (a debit) or created (a credit). - Lots []AdminWalletTraceLot `json:"lots"` -} - -// AdminWalletTraceLot is how much of a row went through one lot, and that lot's chain. -type AdminWalletTraceLot struct { - Amount int64 `json:"amount"` - // The lot itself first, then the lot it was carried over from, and so on. The - // last step is the lot an EARN, ADJUSTMENT or MIGRATION created. - Chain []AdminWalletTraceStep `json:"chain"` -} - -// AdminWalletTraceStep is one lot on a chain and the row that created it. -type AdminWalletTraceStep struct { - Lot AdminWalletLot `json:"lot"` - Source AdminWalletTraceRow `json:"source"` -} - -// AdminWalletTraceRow is a ledger row as a trace shows it, with the real name of the -// customer it belongs to. -type AdminWalletTraceRow struct { - ID uuid.UUID `json:"id"` - Customer AdminWalletNamedRef `json:"customer"` - Currency string `json:"currency"` - Type string `json:"type"` - Amount int64 `json:"amount"` - Description string `json:"description"` - ReferenceType string `json:"reference_type"` - ReferenceID uuid.UUID `json:"reference_id"` - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - CreatedAt time.Time `json:"created_at"` -} diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 9764551..1297e90 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -63,37 +63,27 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, return nil, fmt.Errorf("failed to get payment method analytics: %w", err) } - // EnakPoint is not money in (docs/prd-point-coin.md F9, K7): it is listed as its own - // method but left out of the money received and of the shares. How it is booked - // waits on note N2. - var cashAmount, pointAmount float64 - var pointsUsed int64 + var totalAmount float64 var totalOrders int64 var totalPayments int64 for _, data := range analyticsData { - if data.PaymentMethodType == string(constants.PaymentMethodTypePoint) { - pointAmount += data.TotalAmount - pointsUsed += data.PointsUsed - } else { - cashAmount += data.TotalAmount - } + totalAmount += data.TotalAmount totalOrders += data.OrderCount totalPayments += data.PaymentCount } - // The value of an order includes what EnakPoint paid, so the average does too. var averageOrderValue float64 if totalOrders > 0 { - averageOrderValue = (cashAmount + pointAmount) / float64(totalOrders) + averageOrderValue = totalAmount / float64(totalOrders) } + // Calculate percentages var resultData []models.PaymentMethodAnalyticsData for _, data := range analyticsData { - cashIn := data.PaymentMethodType != string(constants.PaymentMethodTypePoint) var percentage float64 - if cashIn && cashAmount > 0 { - percentage = (data.TotalAmount / cashAmount) * 100 + if totalAmount > 0 { + percentage = (data.TotalAmount / totalAmount) * 100 } resultData = append(resultData, models.PaymentMethodAnalyticsData{ @@ -104,16 +94,11 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, OrderCount: data.OrderCount, PaymentCount: data.PaymentCount, Percentage: percentage, - PointsUsed: data.PointsUsed, - CountsAsCashIn: cashIn, }) } summary := models.PaymentMethodSummary{ - TotalAmount: cashAmount, - PointAmount: pointAmount, - PointsUsed: pointsUsed, - TotalWithPoints: cashAmount + pointAmount, + TotalAmount: totalAmount, TotalOrders: totalOrders, TotalPayments: totalPayments, AverageOrderValue: averageOrderValue, diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 33f9f79..7ae2d0e 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -10,7 +10,6 @@ import ( "apskel-pos-be/internal/models" "github.com/google/uuid" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -25,11 +24,10 @@ type analyticsRepositoryStub struct { exclusiveSummaryCalls int exclusiveSummaryFrom []time.Time exclusiveSummaryTo []time.Time - paymentMethods []*entities.PaymentMethodAnalytics } -func (s analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { - return s.paymentMethods, nil +func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { + return nil, nil } func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) ([]*entities.SalesAnalytics, error) { @@ -572,35 +570,3 @@ func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *t require.Len(t, result.DailySummary, 1) require.Len(t, result.DailyTransactions, 2) } - -// EnakPoint is listed as its own method but is not money in (F9, K7). -func TestPaymentMethodAnalytics_EnakPointIsNotCashIn(t *testing.T) { - repo := &analyticsRepositoryStub{paymentMethods: []*entities.PaymentMethodAnalytics{ - {PaymentMethodName: "Tunai", PaymentMethodType: "cash", TotalAmount: 70000, OrderCount: 2, PaymentCount: 2}, - {PaymentMethodName: "Kartu", PaymentMethodType: "card", TotalAmount: 20000, OrderCount: 1, PaymentCount: 1}, - {PaymentMethodName: "EnakPoint", PaymentMethodType: "point", TotalAmount: 30000, OrderCount: 1, PaymentCount: 1, PointsUsed: 30000}, - }} - p := NewAnalyticsProcessorImpl(repo, nil) - - got, err := p.GetPaymentMethodAnalytics(context.Background(), &models.PaymentMethodAnalyticsRequest{ - OrganizationID: uuid.New(), DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now(), - }) - require.NoError(t, err) - assert.Equal(t, 90000.0, got.Summary.TotalAmount, "money in leaves EnakPoint out") - assert.Equal(t, 30000.0, got.Summary.PointAmount) - assert.Equal(t, int64(30000), got.Summary.PointsUsed) - assert.Equal(t, 120000.0, got.Summary.TotalWithPoints) - assert.Equal(t, int64(4), got.Summary.TotalOrders) - assert.Equal(t, 30000.0, got.Summary.AverageOrderValue, "the value of an order includes what EnakPoint paid") - - byType := map[string]models.PaymentMethodAnalyticsData{} - for _, d := range got.Data { - byType[d.PaymentMethodType] = d - } - assert.True(t, byType["cash"].CountsAsCashIn) - assert.False(t, byType["point"].CountsAsCashIn) - assert.InDelta(t, 77.78, byType["cash"].Percentage, 0.01, "shares are of the money received") - assert.InDelta(t, 22.22, byType["card"].Percentage, 0.01) - assert.Zero(t, byType["point"].Percentage) - assert.Equal(t, int64(30000), byType["point"].PointsUsed) -} diff --git a/internal/processor/campaign_processor.go b/internal/processor/campaign_processor.go index 57f2c8f..2823a26 100644 --- a/internal/processor/campaign_processor.go +++ b/internal/processor/campaign_processor.go @@ -70,7 +70,7 @@ func (p *campaignProcessor) ListCampaigns(ctx context.Context, req *contract.Lis Page: req.Page, Limit: req.Limit, Search: req.Search, - Type: string(entities.NormalizeCampaignType(req.Type)), + Type: 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.NormalizeCampaignRewardType(req.RewardType), + RewardType: entities.CampaignRewardType(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: string(entities.NormalizeCampaignRewardType(req.RewardType)), + RewardType: 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.NormalizeCampaignRewardType(req.RewardType), + RewardType: entities.CampaignRewardType(req.RewardType), RewardValue: req.RewardValue, RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype), RewardRefID: req.RewardRefID, diff --git a/internal/processor/customer_device_processor.go b/internal/processor/customer_device_processor.go deleted file mode 100644 index 419c2f4..0000000 --- a/internal/processor/customer_device_processor.go +++ /dev/null @@ -1,77 +0,0 @@ -package processor - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - - "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/repository" -) - -// ErrInvalidCustomerDevice wraps every rejection of a device registration. -var ErrInvalidCustomerDevice = errors.New("invalid customer device") - -type customerPushSender interface { - SendMulticastNotification(ctx context.Context, tokens []string, title string, body string, data map[string]string) error -} - -// CustomerDeviceProcessor keeps the customer app's FCM tokens and sends push -// notifications to a customer's devices. -type CustomerDeviceProcessor struct { - repo repository.CustomerDeviceRepository - // Nil when FCM is not configured; notifications are then skipped. - fcm customerPushSender - now func() time.Time -} - -func NewCustomerDeviceProcessor(repo repository.CustomerDeviceRepository, fcm customerPushSender) *CustomerDeviceProcessor { - return &CustomerDeviceProcessor{repo: repo, fcm: fcm, now: time.Now} -} - -// Register saves the FCM token the app got for this device. The app calls it after -// login and whenever FCM gives it a new token. -func (p *CustomerDeviceProcessor) Register(ctx context.Context, device repository.CustomerDevice) error { - device.DeviceID = strings.TrimSpace(device.DeviceID) - device.FCMToken = strings.TrimSpace(device.FCMToken) - switch { - case device.DeviceID == "" || len(device.DeviceID) > 255: - return fmt.Errorf("%w: device_id is required, at most 255 characters", ErrInvalidCustomerDevice) - case device.FCMToken == "" || len(device.FCMToken) > 512: - return fmt.Errorf("%w: fcm_token is required, at most 512 characters", ErrInvalidCustomerDevice) - } - if device.Platform != nil { - platform := strings.ToLower(strings.TrimSpace(*device.Platform)) - if platform != "android" && platform != "ios" && platform != "web" { - return fmt.Errorf("%w: platform must be android, ios or web", ErrInvalidCustomerDevice) - } - device.Platform = &platform - } - return p.repo.Register(ctx, device, p.now()) -} - -// Unregister forgets a device, so it stops getting the customer's notifications. -func (p *CustomerDeviceProcessor) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error { - return p.repo.Unregister(ctx, customerID, strings.TrimSpace(deviceID)) -} - -// Notify pushes a notification to every device of the customer through FCM. A -// customer without a registered device gets nothing, which is not an error. -func (p *CustomerDeviceProcessor) Notify(ctx context.Context, customerID uuid.UUID, title, body string, data map[string]string) error { - if p.fcm == nil { - logger.NonContext.Info(fmt.Sprintf("FCM is not configured; not notifying customer %s", customerID)) - return nil - } - tokens, err := p.repo.ListTokens(ctx, customerID) - if err != nil { - return err - } - if len(tokens) == 0 { - return nil - } - return p.fcm.SendMulticastNotification(ctx, tokens, title, body, data) -} diff --git a/internal/processor/customer_device_processor_test.go b/internal/processor/customer_device_processor_test.go deleted file mode 100644 index d8c3156..0000000 --- a/internal/processor/customer_device_processor_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package processor - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/repository" -) - -// customerDeviceRepoFake keeps devices the way the table does: one row per customer -// and device, and a token belongs to one row only. -type customerDeviceRepoFake struct{ devices []repository.CustomerDevice } - -func (f *customerDeviceRepoFake) Register(_ context.Context, d repository.CustomerDevice, _ time.Time) error { - kept := f.devices[:0] - for _, existing := range f.devices { - sameRow := existing.CustomerID == d.CustomerID && existing.DeviceID == d.DeviceID - if !sameRow && existing.FCMToken != d.FCMToken { - kept = append(kept, existing) - } - } - f.devices = append(kept, d) - return nil -} - -func (f *customerDeviceRepoFake) Unregister(_ context.Context, customerID uuid.UUID, deviceID string) error { - kept := f.devices[:0] - for _, d := range f.devices { - if d.CustomerID != customerID || d.DeviceID != deviceID { - kept = append(kept, d) - } - } - f.devices = kept - return nil -} - -func (f *customerDeviceRepoFake) ListTokens(_ context.Context, customerID uuid.UUID) ([]string, error) { - var tokens []string - for _, d := range f.devices { - if d.CustomerID == customerID { - tokens = append(tokens, d.FCMToken) - } - } - return tokens, nil -} - -type fcmFake struct { - tokens [][]string - title string - body string - data map[string]string - err error -} - -func (f *fcmFake) SendMulticastNotification(_ context.Context, tokens []string, title, body string, data map[string]string) error { - f.tokens = append(f.tokens, tokens) - f.title, f.body, f.data = title, body, data - return f.err -} - -func TestCustomerDevice_NotifiesEveryDeviceOfTheCustomer(t *testing.T) { - repo, fcm := &customerDeviceRepoFake{}, &fcmFake{} - p := NewCustomerDeviceProcessor(repo, fcm) - ctx := context.Background() - budi, anita := uuid.New(), uuid.New() - - require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "t1", Platform: ptr("Android")})) - require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "tablet", FCMToken: "t2"})) - // A refreshed token replaces the old one of the same device. - require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "t1b"})) - require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: anita, DeviceID: "phone", FCMToken: "t3"})) - - require.NoError(t, p.Notify(ctx, budi, "EnakPoint masuk", "Kamu menerima 10 EnakPoint", map[string]string{"type": "X"})) - assert.Equal(t, [][]string{{"t2", "t1b"}}, fcm.tokens) - assert.Equal(t, "EnakPoint masuk", fcm.title) - assert.Equal(t, map[string]string{"type": "X"}, fcm.data) -} - -func TestCustomerDevice_TokenMovesToWhoeverLogsInOnThePhone(t *testing.T) { - repo, fcm := &customerDeviceRepoFake{}, &fcmFake{} - p := NewCustomerDeviceProcessor(repo, fcm) - ctx := context.Background() - budi, anita := uuid.New(), uuid.New() - - require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "shared"})) - require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: anita, DeviceID: "phone", FCMToken: "shared"})) - - // Budi's notifications no longer reach the phone Anita is now logged in on. - require.NoError(t, p.Notify(ctx, budi, "t", "b", nil)) - require.NoError(t, p.Notify(ctx, anita, "t", "b", nil)) - assert.Equal(t, [][]string{{"shared"}}, fcm.tokens) -} - -func TestCustomerDevice_NothingToSend(t *testing.T) { - repo, fcm := &customerDeviceRepoFake{}, &fcmFake{} - ctx := context.Background() - customer := uuid.New() - - // No device registered, or logged out: nothing is sent and nothing fails. - require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Notify(ctx, customer, "t", "b", nil)) - require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Register(ctx, repository.CustomerDevice{CustomerID: customer, DeviceID: "phone", FCMToken: "t1"})) - require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Unregister(ctx, customer, "phone")) - require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Notify(ctx, customer, "t", "b", nil)) - assert.Empty(t, fcm.tokens) - - // FCM not configured. - require.NoError(t, NewCustomerDeviceProcessor(repo, nil).Notify(ctx, customer, "t", "b", nil)) -} - -func TestCustomerDevice_FCMFailureIsReported(t *testing.T) { - repo, fcm := &customerDeviceRepoFake{}, &fcmFake{err: errors.New("unavailable")} - p := NewCustomerDeviceProcessor(repo, fcm) - ctx := context.Background() - customer := uuid.New() - require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: customer, DeviceID: "phone", FCMToken: "t1"})) - - assert.Error(t, p.Notify(ctx, customer, "t", "b", nil)) -} - -func TestCustomerDevice_RejectsIncompleteRegistrations(t *testing.T) { - p := NewCustomerDeviceProcessor(&customerDeviceRepoFake{}, nil) - ctx := context.Background() - for name, d := range map[string]repository.CustomerDevice{ - "no device": {FCMToken: "t"}, - "no token": {DeviceID: "phone", FCMToken: " "}, - "bad platform": {DeviceID: "phone", FCMToken: "t", Platform: ptr("symbian")}, - } { - assert.ErrorIs(t, p.Register(ctx, d), ErrInvalidCustomerDevice, name) - } -} diff --git a/internal/processor/customer_pin_processor.go b/internal/processor/customer_pin_processor.go deleted file mode 100644 index 0de4f19..0000000 --- a/internal/processor/customer_pin_processor.go +++ /dev/null @@ -1,471 +0,0 @@ -package processor - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - "golang.org/x/crypto/bcrypt" - - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// PIN rules (docs/prd-point-coin.md F11, Q16, Q17). -const ( - pinLength = 6 - pinMaxAttempts = 5 - pinLockDuration = 30 * time.Minute - pinTransferHold = 24 * time.Hour - pinSecurityReasonN = 255 - - PinOtpPurposeSetup = "pin_setup" - PinOtpPurposeReset = "pin_reset" -) - -// Security log events. -const ( - PinEventSet = "PIN_SET" - PinEventChanged = "PIN_CHANGED" - PinEventReset = "PIN_RESET" - PinEventFailed = "PIN_FAILED" - PinEventLocked = "PIN_LOCKED" - PinEventRemovedByAdmin = "PIN_REMOVED_BY_ADMIN" -) - -// What a PIN approves. Only a transfer is held after a reset. -type PinAction string - -const ( - PinActionPay PinAction = "PAY" - PinActionExchange PinAction = "EXCHANGE" - PinActionTransfer PinAction = "TRANSFER" -) - -// Codes of PinError, which the apps tell apart (docs/prd-point-coin.md §9). -const ( - PinErrNotSet = "PIN_NOT_SET" - PinErrInvalid = "PIN_INVALID" - PinErrLocked = "PIN_LOCKED" - PinErrTransferBlocked = "TRANSFER_BLOCKED" -) - -// PinError is why a PIN did not approve an action. -type PinError struct { - Code string - // Set for PIN_INVALID: attempts left before the PIN locks. - RemainingAttempts int - // Set for PIN_LOCKED and TRANSFER_BLOCKED. - Until *time.Time -} - -func (e *PinError) Error() string { - switch e.Code { - case PinErrNotSet: - return "PIN has not been set" - case PinErrInvalid: - return fmt.Sprintf("wrong PIN, %d attempts left", e.RemainingAttempts) - case PinErrLocked: - return fmt.Sprintf("PIN is locked until %s", e.Until.Format(time.RFC3339)) - case PinErrTransferBlocked: - return fmt.Sprintf("transfers are on hold after a PIN reset until %s", e.Until.Format(time.RFC3339)) - } - return e.Code -} - -var ( - // ErrInvalidPinInput wraps a PIN that is malformed, weak, or not confirmed. The - // message never contains the PIN. - ErrInvalidPinInput = errors.New("invalid PIN") - // ErrPinAlreadySet means a first PIN was requested for a customer who has one. - ErrPinAlreadySet = errors.New("PIN has already been set") - // ErrPinOtpInvalid means the OTP was wrong, expired, used, for another purpose, or - // sent to another number. - ErrPinOtpInvalid = errors.New("invalid or expired OTP") - // ErrPinOtpTooSoon means an OTP was requested again too quickly. - ErrPinOtpTooSoon = errors.New("an OTP was sent recently; wait before asking again") - // ErrPinNoPhone means the customer has no phone number to send an OTP to. - ErrPinNoPhone = errors.New("customer has no phone number") -) - -type pinOtpSender interface { - CanResendOtp(ctx context.Context, phoneNumber string, purpose string) (bool, int, error) - CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) - SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error - ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error) -} - -// NotificationTypePinLocked is the data type of the push a customer gets when their -// PIN locks, so the app can offer the PIN reset. -const NotificationTypePinLocked = "PIN_LOCKED" - -// CustomerPinProcessor manages customer PINs (docs/prd-point-coin.md F11). Every flow -// that moves balance on the customer's request calls VerifyPin first (K8). -type CustomerPinProcessor struct { - repo repository.CustomerPinRepository - otp pinOtpSender - notifier customerNotifier - now func() time.Time - cost int -} - -func NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, notifier customerNotifier) *CustomerPinProcessor { - return &CustomerPinProcessor{repo: repo, otp: otp, notifier: notifier, now: time.Now, cost: bcrypt.DefaultCost} -} - -func (p *CustomerPinProcessor) Status(ctx context.Context, customerID uuid.UUID) (*models.CustomerPinStatus, error) { - state, err := p.repo.GetState(ctx, customerID) - if err != nil { - return nil, err - } - now := p.now() - status := &models.CustomerPinStatus{HasPin: state.PinHash != nil} - if state.LockedUntil != nil && state.LockedUntil.After(now) { - status.LockedUntil = state.LockedUntil - } - if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) { - status.TransferBlockedUntil = state.TransferBlockedUntil - } - return status, nil -} - -// RequestOtp sends an OTP to the customer's own phone number, for creating a first PIN -// (pin_setup) or resetting a forgotten one (pin_reset). -func (p *CustomerPinProcessor) RequestOtp(ctx context.Context, customerID uuid.UUID, purpose string) (*models.CustomerPinOtp, error) { - state, err := p.repo.GetState(ctx, customerID) - if err != nil { - return nil, err - } - switch purpose { - case PinOtpPurposeSetup: - if state.PinHash != nil { - return nil, ErrPinAlreadySet - } - case PinOtpPurposeReset: - if state.PinHash == nil { - return nil, &PinError{Code: PinErrNotSet} - } - default: - return nil, fmt.Errorf("%w: purpose must be %s or %s", ErrInvalidPinInput, PinOtpPurposeSetup, PinOtpPurposeReset) - } - if state.PhoneNumber == nil || *state.PhoneNumber == "" { - return nil, ErrPinNoPhone - } - - canSend, _, err := p.otp.CanResendOtp(ctx, *state.PhoneNumber, purpose) - if err != nil { - return nil, err - } - if !canSend { - return nil, ErrPinOtpTooSoon - } - session, err := p.otp.CreateOtpSession(ctx, *state.PhoneNumber, purpose) - if err != nil { - return nil, err - } - if err := p.otp.SendOtpViaWhatsApp(*state.PhoneNumber, session.Code, purpose); err != nil { - return nil, err - } - return &models.CustomerPinOtp{Purpose: purpose, OtpToken: session.Token, ExpiresAt: session.ExpiresAt}, nil -} - -// CreatePin sets a customer's first PIN, approved by an OTP to their phone so it is set -// by the owner of the number and not by whoever holds a logged-in phone. -func (p *CustomerPinProcessor) CreatePin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error { - state, err := p.repo.GetState(ctx, customerID) - if err != nil { - return err - } - if state.PinHash != nil { - return ErrPinAlreadySet - } - // Check the PIN before spending the OTP, so a weak PIN does not cost a new code. - if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil { - return err - } - if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeSetup); err != nil { - return err - } - hash, err := p.hash(pin) - if err != nil { - return err - } - if err := p.repo.SetPin(ctx, customerID, hash, nil); err != nil { - return err - } - p.logEvent(ctx, customerID, PinEventSet, nil, nil, info) - return nil -} - -// ChangePin replaces the PIN after checking the old one, which counts toward the lock -// like any other attempt. A transfer hold from an earlier reset stays. -func (p *CustomerPinProcessor) ChangePin(ctx context.Context, customerID uuid.UUID, oldPin, pin, confirmPin string, info models.CustomerPinRequestInfo) error { - state, err := p.repo.GetState(ctx, customerID) - if err != nil { - return err - } - if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil { - return err - } - if err := p.verify(ctx, state, oldPin, PinActionPay, info); err != nil { - return err - } - hash, err := p.hash(pin) - if err != nil { - return err - } - if err := p.repo.SetPin(ctx, customerID, hash, p.activeHold(state)); err != nil { - return err - } - p.logEvent(ctx, customerID, PinEventChanged, nil, nil, info) - return nil -} - -// ResetPin sets a new PIN for a customer who forgot theirs, approved by an OTP. It also -// lifts a lock, and holds outgoing transfers for 24 hours in case the phone number was -// taken over (Q16). -func (p *CustomerPinProcessor) ResetPin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error { - state, err := p.repo.GetState(ctx, customerID) - if err != nil { - return err - } - if state.PinHash == nil { - return &PinError{Code: PinErrNotSet} - } - if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil { - return err - } - if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeReset); err != nil { - return err - } - hash, err := p.hash(pin) - if err != nil { - return err - } - hold := p.now().Add(pinTransferHold) - if err := p.repo.SetPin(ctx, customerID, hash, &hold); err != nil { - return err - } - p.logEvent(ctx, customerID, PinEventReset, nil, nil, info) - return nil -} - -// VerifyPin checks the PIN before an action that moves balance. It returns a *PinError -// with the code the apps act on: PIN_NOT_SET, PIN_INVALID (with the attempts left), -// PIN_LOCKED or TRANSFER_BLOCKED (with until when). -func (p *CustomerPinProcessor) VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error { - state, err := p.repo.GetState(ctx, customerID) - if err != nil { - return err - } - return p.verify(ctx, state, pin, action, info) -} - -func (p *CustomerPinProcessor) verify(ctx context.Context, state *repository.CustomerPinState, pin string, action PinAction, info models.CustomerPinRequestInfo) error { - if state.PinHash == nil { - return &PinError{Code: PinErrNotSet} - } - now := p.now() - // A locked PIN is refused before it is compared, even when it is right. - if state.LockedUntil != nil && state.LockedUntil.After(now) { - until := *state.LockedUntil - return &PinError{Code: PinErrLocked, Until: &until} - } - // A held transfer is refused before the PIN is compared, so it costs no attempt. - if action == PinActionTransfer && state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) { - until := *state.TransferBlockedUntil - return &PinError{Code: PinErrTransferBlocked, Until: &until} - } - - if bcrypt.CompareHashAndPassword([]byte(*state.PinHash), []byte(pin)) != nil { - attempts, lockedUntil, err := p.repo.RecordFailure(ctx, state.CustomerID, pinMaxAttempts, now, now.Add(pinLockDuration)) - if err != nil { - return err - } - p.logEvent(ctx, state.CustomerID, PinEventFailed, nil, nil, info) - if lockedUntil != nil && lockedUntil.After(now) { - // Only the attempt that reached the limit logs the lock and tells the - // customer; attempts racing it just see the lock. - if attempts == pinMaxAttempts { - p.logEvent(ctx, state.CustomerID, PinEventLocked, nil, nil, info) - p.alertLocked(ctx, state, *lockedUntil) - } - return &PinError{Code: PinErrLocked, Until: lockedUntil} - } - return &PinError{Code: PinErrInvalid, RemainingAttempts: pinMaxAttempts - attempts} - } - if state.FailedAttempts > 0 || state.LockedUntil != nil { - if err := p.repo.ClearFailures(ctx, state.CustomerID); err != nil { - return err - } - } - return nil -} - -// RemovePinByAdmin deletes a customer's PIN, for example when they lost access to it, -// so they have to create a new one through OTP. Admins can never set or read a PIN. -func (p *CustomerPinProcessor) RemovePinByAdmin(ctx context.Context, organizationID, customerID, adminID uuid.UUID, reason string, info models.CustomerPinRequestInfo) error { - reason = strings.TrimSpace(reason) - if reason == "" { - return fmt.Errorf("%w: a reason is required", ErrInvalidPinInput) - } - if adminID == uuid.Nil { - return fmt.Errorf("%w: the admin is unknown", ErrInvalidPinInput) - } - state, err := p.repo.GetState(ctx, customerID) - if err != nil { - return err - } - if state.OrganizationID != organizationID { - return repository.ErrPinCustomerNotFound - } - if state.PinHash == nil { - return &PinError{Code: PinErrNotSet} - } - if err := p.repo.RemovePin(ctx, customerID); err != nil { - return err - } - reason = truncateRunes(reason, pinSecurityReasonN) - p.logEvent(ctx, customerID, PinEventRemovedByAdmin, &adminID, &reason, info) - return nil -} - -// ListEvents returns a page of a customer's PIN security log for the dashboard. -func (p *CustomerPinProcessor) ListEvents(ctx context.Context, organizationID, customerID uuid.UUID, page, limit int) (*models.PaginatedResponse[models.CustomerSecurityEventView], error) { - state, err := p.repo.GetState(ctx, customerID) - if err != nil { - return nil, err - } - if state.OrganizationID != organizationID { - return nil, repository.ErrPinCustomerNotFound - } - if page < 1 { - page = 1 - } - if limit < 1 || limit > 100 { - limit = 20 - } - rows, total, err := p.repo.ListEvents(ctx, customerID, (page-1)*limit, limit) - if err != nil { - return nil, err - } - events := make([]models.CustomerSecurityEventView, 0, len(rows)) - for _, e := range rows { - events = append(events, models.CustomerSecurityEventView{ - ID: e.ID, Event: e.Event, ActorUser: e.ActorUser, Reason: e.Reason, - IPAddress: e.IPAddress, UserAgent: e.UserAgent, CreatedAt: e.CreatedAt, - }) - } - return &models.PaginatedResponse[models.CustomerSecurityEventView]{ - Data: events, - Pagination: models.Pagination{ - Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit)), - }, - }, nil -} - -// checkOtp validates an OTP and that it was issued for this purpose to this customer's -// own phone number. Without those checks an OTP from the login flow, or one sent to -// another number, could approve a PIN change. -func (p *CustomerPinProcessor) checkOtp(ctx context.Context, state *repository.CustomerPinState, token, code, purpose string) error { - if token == "" || code == "" || state.PhoneNumber == nil { - return ErrPinOtpInvalid - } - session, err := p.otp.ValidateOtpSession(ctx, token, code) - if err != nil || session == nil { - return ErrPinOtpInvalid - } - if session.Purpose != purpose || session.PhoneNumber != *state.PhoneNumber { - return ErrPinOtpInvalid - } - return nil -} - -func (p *CustomerPinProcessor) hash(pin string) (string, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(pin), p.cost) - if err != nil { - return "", fmt.Errorf("failed to hash PIN: %w", err) - } - return string(hash), nil -} - -func (p *CustomerPinProcessor) activeHold(state *repository.CustomerPinState) *time.Time { - if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(p.now()) { - return state.TransferBlockedUntil - } - return nil -} - -// logEvent records a security event. The log is best effort: failing to write it must -// not undo what the customer just did, so a failure is logged instead. -func (p *CustomerPinProcessor) logEvent(ctx context.Context, customerID uuid.UUID, event string, actor *uuid.UUID, reason *string, info models.CustomerPinRequestInfo) { - e := repository.CustomerSecurityEvent{CustomerID: customerID, Event: event, ActorUser: actor, Reason: reason} - if info.IPAddress != "" { - ip := truncateRunes(info.IPAddress, 45) - e.IPAddress = &ip - } - if info.UserAgent != "" { - ua := truncateRunes(info.UserAgent, 255) - e.UserAgent = &ua - } - if err := p.repo.InsertEvent(ctx, e); err != nil { - logger.NonContext.Error(fmt.Sprintf("Could not record %s for customer %s", event, customerID), err) - } -} - -// alertLocked pushes the lock to the customer's app through FCM (F11). It is best -// effort: the lock stands whether or not the push goes out. -func (p *CustomerPinProcessor) alertLocked(ctx context.Context, state *repository.CustomerPinState, until time.Time) { - if p.notifier == nil { - return - } - body := fmt.Sprintf("PIN EnakPoint kamu terkunci sampai %s karena salah dimasukkan %d kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.", - until.In(walletDisplayLocation).Format("02 Jan 2006 15:04 WIB"), pinMaxAttempts) - data := map[string]string{ - "type": NotificationTypePinLocked, - "locked_until": until.UTC().Format(time.RFC3339), - } - if err := p.notifier.Notify(ctx, state.CustomerID, "PIN terkunci", body, data); err != nil { - logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s their PIN is locked", state.CustomerID), err) - } -} - -// checkNewPin rejects a PIN that is not 6 digits, does not match its confirmation, or -// is easy to guess: one digit repeated, a run up or down, or the birth date as DDMMYY -// or YYMMDD. -func checkNewPin(pin, confirm string, birthDate *time.Time) error { - if len(pin) != pinLength { - return fmt.Errorf("%w: a PIN is %d digits", ErrInvalidPinInput, pinLength) - } - for _, r := range pin { - if r < '0' || r > '9' { - return fmt.Errorf("%w: a PIN is digits only", ErrInvalidPinInput) - } - } - if pin != confirm { - return fmt.Errorf("%w: the PIN and its confirmation differ", ErrInvalidPinInput) - } - same, up, down := true, true, true - for i := 1; i < len(pin); i++ { - d := int(pin[i]) - int(pin[i-1]) - same = same && d == 0 - up = up && d == 1 - down = down && d == -1 - } - if same || up || down { - return fmt.Errorf("%w: the PIN is too easy to guess", ErrInvalidPinInput) - } - if birthDate != nil { - for _, layout := range []string{"020106", "060102"} { - if pin == birthDate.Format(layout) { - return fmt.Errorf("%w: the PIN must not be your birth date", ErrInvalidPinInput) - } - } - } - return nil -} diff --git a/internal/processor/customer_pin_processor_db_test.go b/internal/processor/customer_pin_processor_db_test.go deleted file mode 100644 index ca0e8d0..0000000 --- a/internal/processor/customer_pin_processor_db_test.go +++ /dev/null @@ -1,257 +0,0 @@ -package processor - -import ( - "context" - "errors" - "os" - "strings" - "sync" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/crypto/bcrypt" - "gorm.io/driver/postgres" - "gorm.io/gorm" - "gorm.io/gorm/logger" - - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// otpFake keeps OTP sessions in memory with the checks the real one makes. -type otpFake struct { - mu sync.Mutex - sessions map[string]*entities.OtpSession - sent []string -} - -func (f *otpFake) CanResendOtp(context.Context, string, string) (bool, int, error) { - return true, 0, nil -} - -func (f *otpFake) CreateOtpSession(_ context.Context, phone, purpose string) (*entities.OtpSession, error) { - f.mu.Lock() - defer f.mu.Unlock() - s := &entities.OtpSession{Token: uuid.NewString(), Code: "246810", PhoneNumber: phone, Purpose: purpose, ExpiresAt: time.Now().Add(5 * time.Minute)} - f.sessions[s.Token] = s - return s, nil -} - -func (f *otpFake) SendOtpViaWhatsApp(phone, code, purpose string) error { - f.sent = append(f.sent, purpose) - return nil -} - -func (f *otpFake) ValidateOtpSession(_ context.Context, token, code string) (*entities.OtpSession, error) { - f.mu.Lock() - defer f.mu.Unlock() - s := f.sessions[token] - if s == nil || s.IsUsed || s.Code != code { - return nil, errors.New("invalid OTP") - } - s.IsUsed = true - return s, nil -} - -// issue creates a session as if it had been sent, for any purpose and number. -func (f *otpFake) issue(phone, purpose string) *entities.OtpSession { - s, _ := f.CreateOtpSession(context.Background(), phone, purpose) - return s -} - -type alerterFake struct { - mu sync.Mutex - messages []string -} - -func (f *alerterFake) Notify(_ context.Context, _ uuid.UUID, _, body string, _ map[string]string) error { - f.mu.Lock() - defer f.mu.Unlock() - f.messages = append(f.messages, body) - return nil -} - -// Needs TEST_DATABASE_URL pointing at a migrated database; see -// internal/repository/wallet_repository_test.go. -func TestCustomerPin_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, otherOrg, customer, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New() - phone := "0812" + customer.String()[:8] - 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 (?, 'pin test', 'basic'), (?, 'other', 'basic')`, org, otherOrg) - exec(`INSERT INTO customers (id, organization_id, name, phone_number, birth_date) VALUES (?, ?, 'Budi', ?, '1990-03-14')`, customer, org, phone) - t.Cleanup(func() { - db.Exec(`DELETE FROM customer_security_events WHERE customer_id = ?`, customer) - db.Exec(`DELETE FROM customers WHERE id = ?`, customer) - db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg}) - }) - - otp := &otpFake{sessions: map[string]*entities.OtpSession{}} - alerts := &alerterFake{} - p := NewCustomerPinProcessor(repository.NewCustomerPinRepository(db), otp, alerts) - p.cost = bcrypt.MinCost - clock := time.Now() - var clockMu sync.Mutex - p.now = func() time.Time { clockMu.Lock(); defer clockMu.Unlock(); return clock } - advance := func(d time.Duration) { clockMu.Lock(); clock = clock.Add(d); clockMu.Unlock() } - info := models.CustomerPinRequestInfo{IPAddress: "10.0.0.7", UserAgent: "EnakApp/2.0"} - const pin, newPin, resetPin = "482913", "572039", "613408" - - pinErr := func(err error) *PinError { - t.Helper() - var pe *PinError - require.True(t, errors.As(err, &pe), "want a PinError, got %v", err) - for _, secret := range []string{pin, newPin, resetPin} { - assert.NotContains(t, err.Error(), secret, "an error must never contain a PIN") - } - return pe - } - events := func() []string { - t.Helper() - var out []string - require.NoError(t, db.Raw(`SELECT event FROM customer_security_events WHERE customer_id = ? ORDER BY created_at, id`, customer).Scan(&out).Error) - return out - } - - // No PIN yet: nothing can be approved. - status, err := p.Status(ctx, customer) - require.NoError(t, err) - assert.False(t, status.HasPin) - assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info)).Code) - - // Creating the first PIN takes an OTP sent to the customer's own number, for this - // purpose. - sent, err := p.RequestOtp(ctx, customer, PinOtpPurposeSetup) - require.NoError(t, err) - assert.Equal(t, []string{PinOtpPurposeSetup}, otp.sent) - - loginOtp := otp.issue(phone, "login") - assert.ErrorIs(t, p.CreatePin(ctx, customer, loginOtp.Token, loginOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP for another purpose") - strangerOtp := otp.issue("0899999999", PinOtpPurposeSetup) - assert.ErrorIs(t, p.CreatePin(ctx, customer, strangerOtp.Token, strangerOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP sent to another number") - assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "000000", pin, pin, info), ErrPinOtpInvalid, "a wrong code") - - // A weak PIN is refused before the OTP is used, so the same OTP still works after. - assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "123456", "123456", info), ErrInvalidPinInput) - assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "140390", "140390", info), ErrInvalidPinInput, "birth date") - require.NoError(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info)) - assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info), ErrPinAlreadySet) - - var stored string - require.NoError(t, db.Raw(`SELECT pin_hash FROM customers WHERE id = ?`, customer).Scan(&stored).Error) - assert.NotContains(t, stored, pin, "only a hash is stored") - assert.True(t, strings.HasPrefix(stored, "$2"), "bcrypt") - - require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info)) - - // Four wrong attempts count down; the fifth locks for 30 minutes. - for left := 4; left >= 1; left-- { - pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info)) - assert.Equal(t, PinErrInvalid, pe.Code) - assert.Equal(t, left, pe.RemainingAttempts) - } - pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info)) - assert.Equal(t, PinErrLocked, pe.Code) - assert.WithinDuration(t, clock.Add(30*time.Minute), *pe.Until, time.Second) - assert.Len(t, alerts.messages, 1, "the customer is told the PIN locked") - - // While locked even the right PIN is refused. - pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info)) - assert.Equal(t, PinErrLocked, pe.Code) - status, err = p.Status(ctx, customer) - require.NoError(t, err) - assert.NotNil(t, status.LockedUntil) - - // Once the lock runs out a wrong PIN starts a new series of five. - advance(31 * time.Minute) - pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info)) - assert.Equal(t, PinErrInvalid, pe.Code) - assert.Equal(t, 4, pe.RemainingAttempts) - // The right PIN resets the count. - require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info)) - pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info)) - assert.Equal(t, 4, pe.RemainingAttempts) - require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info)) - - // Wrong attempts made at once all count: none slips past the lock. - var wg sync.WaitGroup - for i := 0; i < 8; i++ { - wg.Add(1) - go func() { defer wg.Done(); _ = p.VerifyPin(ctx, customer, "000001", PinActionPay, info) }() - } - wg.Wait() - pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info)) - assert.Equal(t, PinErrLocked, pe.Code) - - // Resetting through OTP lifts the lock and holds transfers for 24 hours. - _, err = p.RequestOtp(ctx, customer, PinOtpPurposeReset) - require.NoError(t, err) - setupOtp := otp.issue(phone, PinOtpPurposeSetup) - assert.ErrorIs(t, p.ResetPin(ctx, customer, setupOtp.Token, setupOtp.Code, resetPin, resetPin, info), ErrPinOtpInvalid, "a setup OTP cannot reset") - resetOtp := otp.issue(phone, PinOtpPurposeReset) - require.NoError(t, p.ResetPin(ctx, customer, resetOtp.Token, resetOtp.Code, resetPin, resetPin, info)) - status, err = p.Status(ctx, customer) - require.NoError(t, err) - assert.Nil(t, status.LockedUntil, "the lock is lifted") - require.NotNil(t, status.TransferBlockedUntil) - assert.WithinDuration(t, clock.Add(24*time.Hour), *status.TransferBlockedUntil, time.Second) - - require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionPay, info), "paying still works") - require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionExchange, info), "exchanging still works") - pe = pinErr(p.VerifyPin(ctx, customer, resetPin, PinActionTransfer, info)) - assert.Equal(t, PinErrTransferBlocked, pe.Code) - var failed int - require.NoError(t, db.Raw(`SELECT pin_failed_attempts FROM customers WHERE id = ?`, customer).Scan(&failed).Error) - assert.Zero(t, failed, "a held transfer costs no attempt") - - // Changing the PIN needs the old one and keeps the transfer hold. - assert.Equal(t, PinErrInvalid, pinErr(p.ChangePin(ctx, customer, "000001", newPin, newPin, info)).Code) - require.NoError(t, p.ChangePin(ctx, customer, resetPin, newPin, newPin, info)) - require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionPay, info)) - assert.Equal(t, PinErrTransferBlocked, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info)).Code) - advance(25 * time.Hour) - require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info), "the hold ends after 24 hours") - - // An admin can remove the PIN, only in their own organization and with a reason. - assert.ErrorIs(t, p.RemovePinByAdmin(ctx, otherOrg, customer, admin, "hilang HP", info), repository.ErrPinCustomerNotFound) - assert.ErrorIs(t, p.RemovePinByAdmin(ctx, org, customer, admin, " ", info), ErrInvalidPinInput) - require.NoError(t, p.RemovePinByAdmin(ctx, org, customer, admin, "hilang HP", info)) - status, err = p.Status(ctx, customer) - require.NoError(t, err) - assert.False(t, status.HasPin) - assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionPay, info)).Code) - - // Every event is in the security log, with where it came from. - got := events() - for _, want := range []string{PinEventSet, PinEventFailed, PinEventLocked, PinEventReset, PinEventChanged, PinEventRemovedByAdmin} { - assert.Contains(t, got, want) - } - page, err := p.ListEvents(ctx, org, customer, 1, 100) - require.NoError(t, err) - assert.EqualValues(t, len(got), page.Pagination.Total) - removed := page.Data[0] - assert.Equal(t, PinEventRemovedByAdmin, removed.Event) - assert.Equal(t, &admin, removed.ActorUser) - assert.Equal(t, "hilang HP", *removed.Reason) - assert.Equal(t, "10.0.0.7", *removed.IPAddress) - _, err = p.ListEvents(ctx, otherOrg, customer, 1, 10) - assert.ErrorIs(t, err, repository.ErrPinCustomerNotFound) - - var locked int - require.NoError(t, db.Raw(`SELECT COUNT(*) FROM customer_security_events WHERE customer_id = ? AND event = ?`, customer, PinEventLocked).Scan(&locked).Error) - assert.Equal(t, locked, len(alerts.messages), "one alert per lock") -} diff --git a/internal/processor/customer_pin_processor_test.go b/internal/processor/customer_pin_processor_test.go deleted file mode 100644 index f99a593..0000000 --- a/internal/processor/customer_pin_processor_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package processor - -import ( - "context" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/crypto/bcrypt" - - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -func TestCheckNewPin(t *testing.T) { - birth := time.Date(1990, 3, 14, 0, 0, 0, 0, time.UTC) - - for _, ok := range []string{"482913", "019283", "135790", "112233"} { - assert.NoError(t, checkNewPin(ok, ok, &birth), ok) - } - for name, c := range map[string][2]string{ - "too short": {"12345", "12345"}, - "too long": {"1234567", "1234567"}, - "not digits": {"12a456", "12a456"}, - "confirmation": {"482913", "482914"}, - "one digit": {"111111", "111111"}, - "zeros": {"000000", "000000"}, - "run up": {"123456", "123456"}, - "run up from 4": {"456789", "456789"}, - "run down": {"654321", "654321"}, - "run down from 9": {"987654", "987654"}, - "birth date DDMMYY": {"140390", "140390"}, - "birth date YYMMDD": {"900314", "900314"}, - } { - err := checkNewPin(c[0], c[1], &birth) - assert.ErrorIs(t, err, ErrInvalidPinInput, name) - assert.NotContains(t, err.Error(), c[0], "%s: the message must not echo the PIN", name) - } - // Without a birth date only the other rules apply. - assert.NoError(t, checkNewPin("140390", "140390", nil)) -} - -// pinRepoFake holds one customer's PIN state, with the lock rules of RecordFailure. -type pinRepoFake struct { - repository.CustomerPinRepository - state repository.CustomerPinState -} - -func (f *pinRepoFake) GetState(context.Context, uuid.UUID) (*repository.CustomerPinState, error) { - s := f.state - return &s, nil -} - -func (f *pinRepoFake) RecordFailure(_ context.Context, _ uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) { - if f.state.LockedUntil != nil && !f.state.LockedUntil.After(now) { - f.state.FailedAttempts, f.state.LockedUntil = 1, nil - } else { - f.state.FailedAttempts++ - if f.state.FailedAttempts >= maxAttempts { - f.state.LockedUntil = &lockUntil - } - } - return f.state.FailedAttempts, f.state.LockedUntil, nil -} - -func (f *pinRepoFake) ClearFailures(context.Context, uuid.UUID) error { - f.state.FailedAttempts, f.state.LockedUntil = 0, nil - return nil -} - -func (f *pinRepoFake) InsertEvent(context.Context, repository.CustomerSecurityEvent) error { - return nil -} - -func TestCustomerPin_LockIsPushedThroughFCM(t *testing.T) { - customer := uuid.New() - hash, err := bcrypt.GenerateFromPassword([]byte("482913"), bcrypt.MinCost) - require.NoError(t, err) - h := string(hash) - repo := &pinRepoFake{state: repository.CustomerPinState{CustomerID: customer, PinHash: &h}} - notifier := ¬ifierFake{} - p := NewCustomerPinProcessor(repo, nil, notifier) - now := time.Date(2026, 9, 30, 3, 0, 0, 0, time.UTC) - p.now = func() time.Time { return now } - ctx := context.Background() - - for i := 0; i < 4; i++ { - _ = p.VerifyPin(ctx, customer, "000000", PinActionPay, models.CustomerPinRequestInfo{}) - } - assert.Empty(t, notifier.pushes[customer], "no push before the PIN locks") - - err = p.VerifyPin(ctx, customer, "000000", PinActionPay, models.CustomerPinRequestInfo{}) - var pinErr *PinError - require.ErrorAs(t, err, &pinErr) - assert.Equal(t, PinErrLocked, pinErr.Code) - - // Attempts while locked do not push again. - _ = p.VerifyPin(ctx, customer, "482913", PinActionPay, models.CustomerPinRequestInfo{}) - - require.Len(t, notifier.pushes[customer], 1) - push := notifier.pushes[customer][0] - assert.Equal(t, "PIN terkunci", push.title) - assert.Equal(t, "PIN EnakPoint kamu terkunci sampai 30 Sep 2026 10:30 WIB karena salah dimasukkan 5 kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.", push.body) - assert.Equal(t, map[string]string{"type": NotificationTypePinLocked, "locked_until": "2026-09-30T03:30:00Z"}, push.data) -} diff --git a/internal/processor/customer_points_processor.go b/internal/processor/customer_points_processor.go index a5a96aa..d65ef2b 100644 --- a/internal/processor/customer_points_processor.go +++ b/internal/processor/customer_points_processor.go @@ -3,6 +3,7 @@ package processor import ( "context" "fmt" + "time" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" @@ -11,85 +12,217 @@ import ( ) type CustomerPointsProcessor struct { - walletQuery *WalletQueryProcessor - gameRepo *repository.GameRepository + customerPointsRepo repository.CustomerPointsRepository + gameRepo *repository.GameRepository } -func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *repository.GameRepository) *CustomerPointsProcessor { +func NewCustomerPointsProcessor(customerPointsRepo repository.CustomerPointsRepository, gameRepo *repository.GameRepository) *CustomerPointsProcessor { return &CustomerPointsProcessor{ - walletQuery: walletQuery, - gameRepo: gameRepo, + customerPointsRepo: customerPointsRepo, + gameRepo: gameRepo, } } -// The three endpoints below are the customer app's balance screens. They read the -// EnakPoint & EnakCoin wallet; customer_points and customer_tokens are no longer used -// for balances (docs/prd-point-coin.md §9). +// Existing gamification methods - placeholder implementations +func (p *CustomerPointsProcessor) CreateCustomerPoints(ctx context.Context, req *models.CreateCustomerPointsRequest) (*models.CustomerPointsResponse, error) { + // TODO: Implement this method + return nil, fmt.Errorf("not implemented") +} + +func (p *CustomerPointsProcessor) GetCustomerPoints(ctx context.Context, id uuid.UUID) (*models.CustomerPointsResponse, error) { + // TODO: Implement this method + return nil, fmt.Errorf("not implemented") +} + +func (p *CustomerPointsProcessor) GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*models.CustomerPointsResponse, error) { + // TODO: Implement this method + return nil, fmt.Errorf("not implemented") +} + +func (p *CustomerPointsProcessor) ListCustomerPoints(ctx context.Context, query *models.ListCustomerPointsQuery) (*models.PaginatedCustomerPointsResponse, error) { + // Return empty paginated response for now + return &models.PaginatedCustomerPointsResponse{ + Data: []models.CustomerPointsResponse{}, + TotalCount: 0, + Page: 1, + Limit: 10, + TotalPages: 0, + }, nil +} + +func (p *CustomerPointsProcessor) UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *models.UpdateCustomerPointsRequest) (*models.CustomerPointsResponse, error) { + // TODO: Implement this method + return nil, fmt.Errorf("not implemented") +} + +func (p *CustomerPointsProcessor) DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error { + // TODO: Implement this method + return fmt.Errorf("not implemented") +} + +func (p *CustomerPointsProcessor) AddPoints(ctx context.Context, customerID uuid.UUID, points int64) (*models.CustomerPointsResponse, error) { + // TODO: Implement this method + return nil, fmt.Errorf("not implemented") +} + +func (p *CustomerPointsProcessor) DeductPoints(ctx context.Context, customerID uuid.UUID, points int64) (*models.CustomerPointsResponse, error) { + // TODO: Implement this method + return nil, fmt.Errorf("not implemented") +} func (p *CustomerPointsProcessor) GetCustomerTotalPointsAPI(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) { - id, err := parseWalletCustomerID(customerID) + // Get total points + totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get customer total points: %w", err) } - data, err := p.walletQuery.LegacyPoints(ctx, id) + + // Get points history (last 10 records) + pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 10) if err != nil { - return nil, fmt.Errorf("failed to get customer points: %w", err) + return nil, fmt.Errorf("failed to get customer points history: %w", err) } + + // Convert to response format + var historyItems []models.PointsHistoryItem + + for _, point := range pointsHistory { + historyItems = append(historyItems, models.PointsHistoryItem{ + ID: point.ID.String(), + Points: point.Balance, + Type: "BALANCE", + Description: "Points balance", + CreatedAt: point.CreatedAt, + }) + } + + var lastUpdated time.Time + if len(pointsHistory) > 0 { + lastUpdated = pointsHistory[0].CreatedAt + } + return &models.GetCustomerPointsResponse{ Status: "SUCCESS", Message: "Customer points retrieved successfully.", - Data: data, + Data: &models.GetCustomerPointsResponseData{ + TotalPoints: totalPoints, + PointsHistory: historyItems, + LastUpdated: lastUpdated, + }, }, nil } func (p *CustomerPointsProcessor) GetCustomerTotalTokensAPI(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) { - id, err := parseWalletCustomerID(customerID) + // Get total tokens + totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get customer total tokens: %w", err) } - data, err := p.walletQuery.LegacyTokens(ctx, id) + + // Get tokens history (last 10 records) + tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 10) if err != nil { - return nil, fmt.Errorf("failed to get customer tokens: %w", err) + return nil, fmt.Errorf("failed to get customer tokens history: %w", err) } + + // Convert to response format + var historyItems []models.TokensHistoryItem + + for _, token := range tokensHistory { + historyItems = append(historyItems, models.TokensHistoryItem{ + ID: token.ID.String(), + Tokens: token.Balance, + Type: string(token.TokenType), + Description: "Tokens balance", + CreatedAt: token.CreatedAt, + }) + } + + var lastUpdated time.Time + if len(tokensHistory) > 0 { + lastUpdated = tokensHistory[0].CreatedAt + } + return &models.GetCustomerTokensResponse{ Status: "SUCCESS", Message: "Customer tokens retrieved successfully.", - Data: data, + Data: &models.GetCustomerTokensResponseData{ + TotalTokens: totalTokens, + TokensHistory: historyItems, + LastUpdated: lastUpdated, + }, }, nil } func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) { - id, err := parseWalletCustomerID(customerID) + // Get total points + totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get customer total points: %w", err) } - data, err := p.walletQuery.Summary(ctx, id) + + // Get total tokens + totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID) if err != nil { - return nil, fmt.Errorf("failed to get customer wallet: %w", err) + return nil, fmt.Errorf("failed to get customer total tokens: %w", err) } + + // Get points history (last 5 records) + pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 5) + if err != nil { + return nil, fmt.Errorf("failed to get customer points history: %w", err) + } + + // Get tokens history (last 5 records) + tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 5) + if err != nil { + return nil, fmt.Errorf("failed to get customer tokens history: %w", err) + } + + // Convert to response format + var pointsHistoryItems []models.PointsHistoryItem + var tokensHistoryItems []models.TokensHistoryItem + var lastUpdated time.Time + + for _, point := range pointsHistory { + pointsHistoryItems = append(pointsHistoryItems, models.PointsHistoryItem{ + ID: point.ID.String(), + Points: point.Balance, + Type: "BALANCE", + Description: "Points balance", + CreatedAt: point.CreatedAt, + }) + if point.CreatedAt.After(lastUpdated) { + lastUpdated = point.CreatedAt + } + } + + for _, token := range tokensHistory { + tokensHistoryItems = append(tokensHistoryItems, models.TokensHistoryItem{ + ID: token.ID.String(), + Tokens: token.Balance, + Type: string(token.TokenType), + Description: "Tokens balance", + CreatedAt: token.CreatedAt, + }) + if token.CreatedAt.After(lastUpdated) { + lastUpdated = token.CreatedAt + } + } + return &models.GetCustomerWalletResponse{ Status: "SUCCESS", Message: "Customer wallet retrieved successfully.", - Data: data, + Data: &models.GetCustomerWalletResponseData{ + TotalPoints: totalPoints, + TotalTokens: totalTokens, + PointsHistory: pointsHistoryItems, + TokensHistory: tokensHistoryItems, + LastUpdated: lastUpdated, + }, }, nil } -func (p *CustomerPointsProcessor) GetCustomerWalletTransactionsAPI(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) { - id, err := parseWalletCustomerID(customerID) - if err != nil { - return nil, err - } - return p.walletQuery.Transactions(ctx, id, query) -} - -func parseWalletCustomerID(customerID string) (uuid.UUID, error) { - id, err := uuid.Parse(customerID) - if err != nil { - return uuid.Nil, fmt.Errorf("%w: invalid customer id", ErrInvalidWalletQuery) - } - return id, nil -} - // GetCustomerGamesAPI gets active SPIN games for customers func (p *CustomerPointsProcessor) GetCustomerGamesAPI(ctx context.Context) (*models.GetCustomerGamesResponse, error) { // Get active SPIN games @@ -177,11 +310,3 @@ func (p *CustomerPointsProcessor) GetFerrisWheelGameAPI(ctx context.Context) (*m }, }, nil } - -func (p *CustomerPointsProcessor) GetCustomerWalletExpiringAPI(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) { - id, err := parseWalletCustomerID(customerID) - if err != nil { - return nil, err - } - return p.walletQuery.Expiring(ctx, id) -} diff --git a/internal/processor/customer_tokens_processor.go b/internal/processor/customer_tokens_processor.go new file mode 100644 index 0000000..f66e693 --- /dev/null +++ b/internal/processor/customer_tokens_processor.go @@ -0,0 +1,198 @@ +package processor + +import ( + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/mappers" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" + "context" + "errors" + "fmt" + + "github.com/google/uuid" +) + +type CustomerTokensProcessor struct { + customerTokensRepo *repository.CustomerTokensRepository +} + +func NewCustomerTokensProcessor(customerTokensRepo *repository.CustomerTokensRepository) *CustomerTokensProcessor { + return &CustomerTokensProcessor{ + customerTokensRepo: customerTokensRepo, + } +} + +// CreateCustomerTokens creates a new customer tokens record +func (p *CustomerTokensProcessor) CreateCustomerTokens(ctx context.Context, req *models.CreateCustomerTokensRequest) (*models.CustomerTokensResponse, error) { + // Convert request to entity + customerTokens := mappers.ToCustomerTokensEntity(req) + + // Create customer tokens + err := p.customerTokensRepo.Create(ctx, customerTokens) + if err != nil { + return nil, fmt.Errorf("failed to create customer tokens: %w", err) + } + + return mappers.ToCustomerTokensResponse(customerTokens), nil +} + +// GetCustomerTokens retrieves customer tokens by ID +func (p *CustomerTokensProcessor) GetCustomerTokens(ctx context.Context, id uuid.UUID) (*models.CustomerTokensResponse, error) { + customerTokens, err := p.customerTokensRepo.GetByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("customer tokens not found: %w", err) + } + + return mappers.ToCustomerTokensResponse(customerTokens), nil +} + +// GetCustomerTokensByCustomerIDAndType retrieves customer tokens by customer ID and token type +func (p *CustomerTokensProcessor) GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*models.CustomerTokensResponse, error) { + customerTokens, err := p.customerTokensRepo.EnsureCustomerTokens(ctx, customerID, entities.TokenType(tokenType)) + if err != nil { + return nil, fmt.Errorf("failed to get customer tokens: %w", err) + } + + return mappers.ToCustomerTokensResponse(customerTokens), nil +} + +// ListCustomerTokens retrieves customer tokens with pagination and filtering +func (p *CustomerTokensProcessor) ListCustomerTokens(ctx context.Context, query *models.ListCustomerTokensQuery) (*models.PaginatedResponse[models.CustomerTokensResponse], error) { + // Set default values + if query.Page <= 0 { + query.Page = 1 + } + if query.Limit <= 0 { + query.Limit = 10 + } + if query.Limit > 100 { + query.Limit = 100 + } + + offset := (query.Page - 1) * query.Limit + + // Get customer tokens from repository + customerTokens, total, err := p.customerTokensRepo.List( + ctx, + offset, + query.Limit, + query.Search, + query.TokenType, + query.SortBy, + query.SortOrder, + ) + if err != nil { + return nil, fmt.Errorf("failed to list customer tokens: %w", err) + } + + // Convert to responses + responses := mappers.ToCustomerTokensResponses(customerTokens) + + // Calculate pagination info + totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit)) + + return &models.PaginatedResponse[models.CustomerTokensResponse]{ + Data: responses, + Pagination: models.Pagination{ + Page: query.Page, + Limit: query.Limit, + Total: total, + TotalPages: totalPages, + }, + }, nil +} + +// UpdateCustomerTokens updates an existing customer tokens record +func (p *CustomerTokensProcessor) UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *models.UpdateCustomerTokensRequest) (*models.CustomerTokensResponse, error) { + // Get existing customer tokens + customerTokens, err := p.customerTokensRepo.GetByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("customer tokens not found: %w", err) + } + + // Update customer tokens fields + mappers.UpdateCustomerTokensEntity(customerTokens, req) + + // Save updated customer tokens + err = p.customerTokensRepo.Update(ctx, customerTokens) + if err != nil { + return nil, fmt.Errorf("failed to update customer tokens: %w", err) + } + + return mappers.ToCustomerTokensResponse(customerTokens), nil +} + +// DeleteCustomerTokens deletes a customer tokens record +func (p *CustomerTokensProcessor) DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error { + // Get existing customer tokens + _, err := p.customerTokensRepo.GetByID(ctx, id) + if err != nil { + return fmt.Errorf("customer tokens not found: %w", err) + } + + // Delete customer tokens + err = p.customerTokensRepo.Delete(ctx, id) + if err != nil { + return fmt.Errorf("failed to delete customer tokens: %w", err) + } + + return nil +} + +// AddTokens adds tokens to a customer's balance +func (p *CustomerTokensProcessor) AddTokens(ctx context.Context, customerID uuid.UUID, tokenType string, tokens int64) (*models.CustomerTokensResponse, error) { + if tokens <= 0 { + return nil, errors.New("tokens must be greater than 0") + } + + // Ensure customer tokens record exists + _, err := p.customerTokensRepo.EnsureCustomerTokens(ctx, customerID, entities.TokenType(tokenType)) + if err != nil { + return nil, fmt.Errorf("failed to ensure customer tokens: %w", err) + } + + // Add tokens + err = p.customerTokensRepo.AddTokens(ctx, customerID, entities.TokenType(tokenType), tokens) + if err != nil { + return nil, fmt.Errorf("failed to add tokens: %w", err) + } + + // Get updated customer tokens + customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType)) + if err != nil { + return nil, fmt.Errorf("failed to get updated customer tokens: %w", err) + } + + return mappers.ToCustomerTokensResponse(customerTokens), nil +} + +// DeductTokens deducts tokens from a customer's balance +func (p *CustomerTokensProcessor) DeductTokens(ctx context.Context, customerID uuid.UUID, tokenType string, tokens int64) (*models.CustomerTokensResponse, error) { + if tokens <= 0 { + return nil, errors.New("tokens must be greater than 0") + } + + // Get current customer tokens + customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType)) + if err != nil { + return nil, fmt.Errorf("customer tokens not found: %w", err) + } + + if customerTokens.Balance < tokens { + return nil, errors.New("insufficient tokens balance") + } + + // Deduct tokens + err = p.customerTokensRepo.DeductTokens(ctx, customerID, entities.TokenType(tokenType), tokens) + if err != nil { + return nil, fmt.Errorf("failed to deduct tokens: %w", err) + } + + // Get updated customer tokens + updatedCustomerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType)) + if err != nil { + return nil, fmt.Errorf("failed to get updated customer tokens: %w", err) + } + + return mappers.ToCustomerTokensResponse(updatedCustomerTokens), nil +} diff --git a/internal/processor/earning_calculator.go b/internal/processor/earning_calculator.go deleted file mode 100644 index 28825c4..0000000 --- a/internal/processor/earning_calculator.go +++ /dev/null @@ -1,85 +0,0 @@ -package processor - -import ( - "math" - - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" -) - -// EarningLine is what an order earns in one currency, and the settings that produced -// it. The settings are kept so the ledger row can freeze them: a later change of -// setting must not change what an earning meant, and a reversal is computed with the -// same numbers (docs/prd-point-coin.md F3). -type EarningLine struct { - Amount int64 - Settings models.LoyaltyEarnSettings - // True when max_per_order cut the amount down. - Capped bool -} - -// EarningResult is what an order earns. -type EarningResult struct { - // subtotal − discount − the part paid with EnakPoint, in rupiah, never negative. - // Tax and anything else added on top of the subtotal are not part of it (Q1). - Basis float64 - Point EarningLine - Coin EarningLine -} - -// Metadata is the snapshot stored on the currency's EARN ledger row. -func (r EarningResult) Metadata(line EarningLine) entities.Metadata { - m := entities.Metadata{ - "basis": r.Basis, - "earn_per_amount": line.Settings.EarnPerAmount, - "earn_value": line.Settings.EarnValue, - "min_order_amount": line.Settings.MinOrderAmount, - "capped": line.Capped, - } - if line.Settings.MaxPerOrder != nil { - m["max_per_order"] = *line.Settings.MaxPerOrder - } - return m -} - -// CalculateEarning applies the earning formula of docs/prd-point-coin.md F1: -// -// basis = subtotal − discount_amount − paid with EnakPoint -// amount = 0 if basis < min_order_amount -// amount = floor(basis / earn_per_amount) × earn_value -// amount = min(amount, max_per_order) if max_per_order is set -// -// The part paid with EnakPoint earns nothing (Q10). Money is handled in whole cents so -// floor never lands one short on a value like 87500.00 that float64 cannot hold -// exactly. It has no side effects. -func CalculateEarning(order *entities.Order, pointPaidAmount float64, settings models.OutletLoyaltySettings) EarningResult { - basisCents := toCents(order.Subtotal) - toCents(order.DiscountAmount) - toCents(pointPaidAmount) - if basisCents < 0 { - basisCents = 0 - } - return EarningResult{ - Basis: float64(basisCents) / 100, - Point: earnLine(basisCents, settings.Point), - Coin: earnLine(basisCents, settings.Coin), - } -} - -func earnLine(basisCents int64, s models.LoyaltyEarnSettings) EarningLine { - line := EarningLine{Settings: s} - if !s.Enabled || s.EarnPerAmount <= 0 || s.EarnValue <= 0 { - return line - } - if basisCents < s.MinOrderAmount*100 { - return line - } - line.Amount = basisCents / (s.EarnPerAmount * 100) * s.EarnValue - if s.MaxPerOrder != nil && line.Amount > *s.MaxPerOrder { - line.Amount = *s.MaxPerOrder - line.Capped = true - } - return line -} - -func toCents(rupiah float64) int64 { - return int64(math.Round(rupiah * 100)) -} diff --git a/internal/processor/earning_calculator_test.go b/internal/processor/earning_calculator_test.go deleted file mode 100644 index b49efab..0000000 --- a/internal/processor/earning_calculator_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package processor - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" -) - -// 1 EnakPoint per Rp 100 and 1 EnakCoin per Rp 25.000, as in the F1 example. -func prdEarningSettings() models.OutletLoyaltySettings { - return models.OutletLoyaltySettings{ - Point: models.LoyaltyEarnSettings{Enabled: true, EarnPerAmount: 100, EarnValue: 1}, - Coin: models.LoyaltyEarnSettings{Enabled: true, EarnPerAmount: 25000, EarnValue: 1}, - } -} - -func TestCalculateEarning_PRDExample(t *testing.T) { - // Subtotal after discount Rp 87.500, paid in full in cash. - order := &entities.Order{Subtotal: 97500, DiscountAmount: 10000, TaxAmount: 9625, TotalAmount: 97125} - - got := CalculateEarning(order, 0, prdEarningSettings()) - assert.Equal(t, 87500.0, got.Basis) - assert.Equal(t, int64(875), got.Point.Amount) - assert.Equal(t, int64(3), got.Coin.Amount) - - // Rp 20.000 of it paid with EnakPoint earns nothing. - got = CalculateEarning(order, 20000, prdEarningSettings()) - assert.Equal(t, 67500.0, got.Basis) - assert.Equal(t, int64(675), got.Point.Amount) - assert.Equal(t, int64(2), got.Coin.Amount) -} - -func TestCalculateEarning_TaxIsNotPartOfTheBasis(t *testing.T) { - withoutTax := &entities.Order{Subtotal: 50000} - withTax := &entities.Order{Subtotal: 50000, TaxAmount: 5500, TotalAmount: 55500} - assert.Equal(t, CalculateEarning(withoutTax, 0, prdEarningSettings()), CalculateEarning(withTax, 0, prdEarningSettings())) - assert.Equal(t, int64(500), CalculateEarning(withTax, 0, prdEarningSettings()).Point.Amount) -} - -func TestCalculateEarning_BelowMinimum(t *testing.T) { - s := prdEarningSettings() - s.Point.MinOrderAmount = 50000 - - assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount) - assert.Equal(t, int64(500), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount, "the minimum itself earns") - // The minimum applies to the basis, after discount and EnakPoint. - assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 60000}, 15000, s).Point.Amount) - // Coin has its own minimum. - assert.Equal(t, int64(1), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Coin.Amount) -} - -func TestCalculateEarning_MaxPerOrder(t *testing.T) { - s := prdEarningSettings() - max := int64(300) - s.Point.MaxPerOrder = &max - - got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s) - assert.Equal(t, int64(300), got.Point.Amount) - assert.True(t, got.Point.Capped) - assert.Equal(t, int64(300), got.Metadata(got.Point)["max_per_order"]) - - got = CalculateEarning(&entities.Order{Subtotal: 20000}, 0, s) - assert.Equal(t, int64(200), got.Point.Amount) - assert.False(t, got.Point.Capped) - - zero := int64(0) - s.Point.MaxPerOrder = &zero - assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount) -} - -func TestCalculateEarning_DisabledEarnsNothing(t *testing.T) { - s := prdEarningSettings() - s.Point.Enabled = false - got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s) - assert.Equal(t, int64(0), got.Point.Amount) - assert.Equal(t, int64(3), got.Coin.Amount, "each currency is switched on its own") - - s.Coin.Enabled = false - got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s) - assert.Equal(t, int64(0), got.Coin.Amount) - assert.Equal(t, 87500.0, got.Basis, "the basis is still reported") - - // The defaults of an outlet that never set anything earn nothing. - var defaults models.OutletLoyaltySettings - loadLoyaltyFields(outletLoyaltyFields(&defaults), nil, "test") - got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, defaults) - assert.Equal(t, int64(0), got.Point.Amount) - assert.Equal(t, int64(0), got.Coin.Amount) -} - -func TestCalculateEarning_EdgeCases(t *testing.T) { - s := prdEarningSettings() - - // floor, not round. - assert.Equal(t, int64(875), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount) - // Values float64 cannot hold exactly do not lose a point: computed in float64 this - // basis divides to 4956.999…, which a naive floor turns into 4956. - per250 := prdEarningSettings() - per250.Point.EarnPerAmount = 250 - assert.Equal(t, int64(4957), CalculateEarning(&entities.Order{Subtotal: 1240155.48, DiscountAmount: 749.11}, 156.37, per250).Point.Amount) - // Paying more with EnakPoint than the basis leaves nothing, never a negative amount. - got := CalculateEarning(&entities.Order{Subtotal: 10000}, 15000, s) - assert.Equal(t, 0.0, got.Basis) - assert.Equal(t, int64(0), got.Point.Amount) - // earn_value multiplies. - s.Point.EarnValue = 5 - assert.Equal(t, int64(4375), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount) - // A zero earn_value earns nothing even when enabled. - s.Point.EarnValue = 0 - assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount) -} - -func TestCalculateEarning_MetadataSnapshot(t *testing.T) { - got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, prdEarningSettings()) - assert.Equal(t, entities.Metadata{ - "basis": 87500.0, "earn_per_amount": int64(100), "earn_value": int64(1), "min_order_amount": int64(0), "capped": false, - }, got.Metadata(got.Point)) -} diff --git a/internal/processor/earning_processor.go b/internal/processor/earning_processor.go deleted file mode 100644 index b820ea1..0000000 --- a/internal/processor/earning_processor.go +++ /dev/null @@ -1,217 +0,0 @@ -package processor - -import ( - "context" - "fmt" - "time" - - "github.com/google/uuid" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// Why an order earned nothing. -const ( - EarningSkipNotPaid = "NOT_PAID" - EarningSkipVoid = "VOID" - EarningSkipNoCustomer = "NO_CUSTOMER" - EarningSkipDefaultCustomer = "DEFAULT_CUSTOMER" - EarningSkipInactiveCustomer = "INACTIVE_CUSTOMER" - EarningSkipNothingToEarn = "NOTHING_TO_EARN" -) - -// EarningOutcome is what earning did for one order. -type EarningOutcome struct { - Points int64 - Coins int64 - // Set when the order earned nothing, to say why. - Skipped string -} - -type outletSettingsReader interface { - Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) -} - -// earningSettingsReader gives the outlet's earning settings and the organization's -// expiry. LoyaltySettingsProcessor is one. -type earningSettingsReader interface { - outletSettingsReader - organizationSettingsReader -} - -// EarningProcessor credits EnakPoint and EnakCoin for paid orders -// (docs/prd-point-coin.md F3). -type EarningProcessor struct { - orders repository.EarningRepository - settings earningSettingsReader - now func() time.Time - wallet *WalletProcessor - tx TxRunner -} - -func NewEarningProcessor(orders repository.EarningRepository, settings earningSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor { - return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx, now: time.Now} -} - -// OnOrderPaid is called once an order has become fully paid and the payment has -// committed. It never fails the caller: a failed earning is logged and picked up later -// by EarnMissing, and the idempotency keys make that retry safe. -func (p *EarningProcessor) OnOrderPaid(ctx context.Context, orderID uuid.UUID) { - defer func() { - if r := recover(); r != nil { - logger.NonContext.Error(fmt.Sprintf("Earning for order %s panicked; it will be retried", orderID), fmt.Errorf("%v", r)) - } - }() - if _, err := p.EarnForOrder(ctx, orderID); err != nil { - logger.NonContext.Error(fmt.Sprintf("Earning for order %s failed; it will be retried", orderID), err) - } -} - -// EarnForOrder credits what a paid order earns. Calling it again for the same order -// credits nothing more. -func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID) (*EarningOutcome, error) { - order, err := p.orders.GetOrderForEarning(ctx, orderID) - if err != nil { - return nil, err - } - if skip := earningSkipReason(order); skip != "" { - return &EarningOutcome{Skipped: skip}, nil - } - - settings, err := p.settings.Outlet(ctx, order.OutletID) - if err != nil { - return nil, err - } - pointPaid, err := p.orders.PointPaidAmount(ctx, orderID) - if err != nil { - return nil, err - } - result := CalculateEarning(&entities.Order{Subtotal: order.Subtotal, DiscountAmount: order.DiscountAmount}, pointPaid, *settings) - if result.Point.Amount == 0 && result.Coin.Amount == 0 { - return &EarningOutcome{Skipped: EarningSkipNothingToEarn}, nil - } - - orgSettings, err := p.settings.Organization(ctx, order.OrganizationID) - if err != nil { - return nil, err - } - receivedAt := p.now() - - outcome := &EarningOutcome{} - err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { - for _, c := range []struct { - currency string - line EarningLine - total *int64 - }{ - {constants.WalletCurrencyPoint, result.Point, &outcome.Points}, - {constants.WalletCurrencyCoin, result.Coin, &outcome.Coins}, - } { - if c.line.Amount == 0 { - continue - } - outletID := order.OutletID - res, err := p.wallet.Credit(ctx, WalletCreditInput{ - WalletEntry: WalletEntry{ - CustomerID: *order.CustomerID, - Currency: c.currency, - Type: constants.WalletTxTypeEarn, - Amount: c.line.Amount, - ReferenceType: constants.WalletRefTypeOrder, - ReferenceID: order.ID, - OutletID: &outletID, - Description: earningDescription(order), - Metadata: result.Metadata(c.line), - IdempotencyKey: fmt.Sprintf("earn:%s:%s", order.ID, c.currency), - }, - Lots: []WalletLotInput{{ - Amount: c.line.Amount, - ExpiresAt: ComputeExpiry(ExpirySettings(orgSettings, c.currency), receivedAt), - }}, - }) - if err != nil { - return fmt.Errorf("crediting %s: %w", c.currency, err) - } - *c.total = res.Transaction.Amount - } - return nil - }) - if err != nil { - return nil, err - } - return outcome, nil -} - -// EarnMissing is the safety net behind OnOrderPaid: it looks for orders paid since the -// given time that should have earned and have no EARN row, and earns for them. It -// returns how many orders it looked at and how many now earned. One order failing does -// not stop the others. -func (p *EarningProcessor) EarnMissing(ctx context.Context, since time.Time, maxOrders int) (checked, earned int, err error) { - const page = 200 - var after *repository.EarningCursor - for checked < maxOrders { - batch, err := p.orders.ListPaidOrdersWithoutEarning(ctx, since, after, page) - if err != nil { - return checked, earned, err - } - if len(batch) == 0 { - break - } - for _, candidate := range batch { - checked++ - outcome, err := p.EarnForOrder(ctx, candidate.ID) - if err != nil { - logger.NonContext.Error(fmt.Sprintf("Earning retry for order %s failed", candidate.ID), err) - continue - } - if outcome.Skipped == "" { - earned++ - } - } - last := batch[len(batch)-1] - after = &last - } - return checked, earned, nil -} - -func earningSkipReason(order *repository.EarningOrder) string { - switch { - case order.PaymentStatus != string(entities.PaymentStatusCompleted): - return EarningSkipNotPaid - case order.IsVoid: - return EarningSkipVoid - case order.CustomerID == nil || order.CustomerIsDefault == nil: - return EarningSkipNoCustomer - case *order.CustomerIsDefault: - return EarningSkipDefaultCustomer - case order.CustomerIsActive == nil || !*order.CustomerIsActive: - return EarningSkipInactiveCustomer - } - return "" -} - -func earningDescription(order *repository.EarningOrder) string { - description := "Belanja #" + order.OrderNumber - if order.OutletName != "" { - description += " di " + order.OutletName - } - return truncateRunes(description, walletDescriptionLimit) -} - -// EarnedByOrders returns what each order earned, as written in its EARN rows, for the -// order screen and the receipt (F3). Orders that earned nothing are absent. -func (p *EarningProcessor) EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error) { - rows, err := p.orders.EarnedByOrders(ctx, orderIDs) - if err != nil { - return nil, err - } - out := make(map[uuid.UUID]OrderEarned, len(rows)) - for id, byCurrency := range rows { - out[id] = OrderEarned{Points: byCurrency[constants.WalletCurrencyPoint], Coins: byCurrency[constants.WalletCurrencyCoin]} - } - return out, nil -} diff --git a/internal/processor/earning_processor_db_test.go b/internal/processor/earning_processor_db_test.go deleted file mode 100644 index 7fad218..0000000 --- a/internal/processor/earning_processor_db_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package processor - -import ( - "context" - "errors" - "os" - "sync" - "testing" - "time" - - "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/models" - "apskel-pos-be/internal/repository" -) - -// failingSettings fails for the outlet settings until healed, to stand in for the -// database being unreachable right after a payment. -type failingSettings struct { - real earningSettingsReader - mu sync.Mutex - fail bool -} - -func (f *failingSettings) Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) { - f.mu.Lock() - fail := f.fail - f.mu.Unlock() - if fail { - return nil, errors.New("connection refused") - } - return f.real.Outlet(ctx, outletID) -} - -// Needs TEST_DATABASE_URL pointing at a migrated database; see -// internal/repository/wallet_repository_test.go. -func TestEarningProcessor_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, user := uuid.New(), uuid.New() - earningOutlet, quietOutlet := uuid.New(), uuid.New() - regular, inactive := uuid.New(), uuid.New() - var walkIn uuid.UUID - var customers []uuid.UUID - 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 (?, 'earning test', 'basic')`, org) - exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, user, org, user.String()+"@test") - exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang'), (?, ?, 'Tanpa Poin')`, earningOutlet, org, quietOutlet, org) - exec(`INSERT INTO customers (id, organization_id, name, is_default, is_active) VALUES - (?, ?, 'Budi', false, true), (?, ?, 'Nonaktif', false, false)`, regular, org, inactive, org) - // Creating the organization created its walk-in customer (trigger_create_default_customer). - var walkInID string - require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, org).Scan(&walkInID).Error) - walkIn = uuid.MustParse(walkInID) - customers = []uuid.UUID{regular, walkIn, inactive} - 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 orders WHERE organization_id = ?`, org) - db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org) - db.Exec(`DELETE FROM outlet_settings WHERE outlet_id IN ?`, []uuid.UUID{earningOutlet, quietOutlet}) - db.Exec(`DELETE FROM customers WHERE id IN ?`, customers) - db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{earningOutlet, quietOutlet}) - db.Exec(`DELETE FROM users WHERE id = ?`, user) - db.Exec(`DELETE FROM organizations WHERE id = ?`, org) - }) - - txm := repository.NewTxManager(db) - settingsProcessor := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) - outletSettings, err := settingsProcessor.Outlet(ctx, earningOutlet) - require.NoError(t, err) - outletSettings.Point.Enabled = true - outletSettings.Coin.Enabled = true - _, err = settingsProcessor.UpdateOutlet(ctx, org, earningOutlet, user, *outletSettings) - require.NoError(t, err) - - settings := &failingSettings{real: settingsProcessor} - earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, NewWalletProcessor(repository.NewWalletRepository(db)), txm) - - orderNo := 0 - newOrder := func(outlet uuid.UUID, customer *uuid.UUID, paymentStatus string, isVoid bool, subtotal, discount float64) uuid.UUID { - t.Helper() - orderNo++ - id := uuid.New() - exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type, - subtotal, discount_amount, tax_amount, total_amount, payment_status, is_void) - VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, ?, 0, ?, ?, ?)`, - id, org, outlet, user, customer, id.String()[:8]+"-"+string(rune('A'+orderNo)), subtotal, discount, subtotal-discount, paymentStatus, isVoid) - return id - } - earnRows := func(orderID uuid.UUID) map[string]int64 { - t.Helper() - var rows []struct { - Currency string - Amount int64 - } - require.NoError(t, db.Raw(`SELECT currency, amount FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, orderID).Scan(&rows).Error) - out := map[string]int64{} - for _, r := range rows { - out[r.Currency] += r.Amount - } - return out - } - - // Paid in full: the PRD example, 875 EnakPoint and 3 EnakCoin. - paid := newOrder(earningOutlet, ®ular, "completed", false, 97500, 10000) - earning.OnOrderPaid(ctx, paid) - assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid)) - - var row struct { - Description string - Metadata string - OutletID string - } - require.NoError(t, db.Raw(`SELECT description, metadata::text AS metadata, outlet_id::text AS outlet_id FROM wallet_transactions - WHERE reference_id = ? AND currency = 'POINT'`, paid).Scan(&row).Error) - assert.Contains(t, row.Description, "di Kemang") - assert.Contains(t, row.Metadata, `"earn_per_amount": 100`, "the settings used are frozen on the row") - assert.Contains(t, row.Metadata, `"basis": 87500`) - assert.Equal(t, earningOutlet.String(), row.OutletID) - - // Called again, and five times at once, it is still one earning. - var wg sync.WaitGroup - for i := 0; i < 5; i++ { - wg.Add(1) - go func() { defer wg.Done(); earning.OnOrderPaid(ctx, paid) }() - } - wg.Wait() - outcome, err := earning.EarnForOrder(ctx, paid) - require.NoError(t, err) - assert.Equal(t, int64(875), outcome.Points, "a repeat reports the first earning") - assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid)) - - // A self-order goes through the same payment path and the same rules. - selfOrder := newOrder(earningOutlet, ®ular, "completed", false, 25000, 0) - earning.OnOrderPaid(ctx, selfOrder) - assert.Equal(t, map[string]int64{"POINT": 250, "COIN": 1}, earnRows(selfOrder)) - - // A split bill earns once, on the payment that settles it: while partial, nothing. - split := newOrder(earningOutlet, ®ular, "partial", false, 60000, 0) - earning.OnOrderPaid(ctx, split) - assert.Empty(t, earnRows(split)) - exec(`UPDATE orders SET payment_status = 'completed' WHERE id = ?`, split) - earning.OnOrderPaid(ctx, split) - assert.Equal(t, map[string]int64{"POINT": 600, "COIN": 2}, earnRows(split)) - - // Orders that must not earn. - for name, c := range map[string]struct { - id uuid.UUID - skip string - }{ - "walk-in customer": {newOrder(earningOutlet, &walkIn, "completed", false, 50000, 0), EarningSkipDefaultCustomer}, - "inactive customer": {newOrder(earningOutlet, &inactive, "completed", false, 50000, 0), EarningSkipInactiveCustomer}, - "no customer": {newOrder(earningOutlet, nil, "completed", false, 50000, 0), EarningSkipNoCustomer}, - "void": {newOrder(earningOutlet, ®ular, "completed", true, 50000, 0), EarningSkipVoid}, - "unpaid": {newOrder(earningOutlet, ®ular, "pending", false, 50000, 0), EarningSkipNotPaid}, - "outlet not earning": {newOrder(quietOutlet, ®ular, "completed", false, 50000, 0), EarningSkipNothingToEarn}, - } { - outcome, err := earning.EarnForOrder(ctx, c.id) - require.NoError(t, err, name) - assert.Equal(t, c.skip, outcome.Skipped, name) - assert.Empty(t, earnRows(c.id), name) - } - - // An earning that fails does not surface to the payment, and the job picks it up. - settings.mu.Lock() - settings.fail = true - settings.mu.Unlock() - missed := newOrder(earningOutlet, ®ular, "completed", false, 40000, 0) - assert.NotPanics(t, func() { earning.OnOrderPaid(ctx, missed) }) - assert.Empty(t, earnRows(missed)) - - settings.mu.Lock() - settings.fail = false - settings.mu.Unlock() - since := time.Now().Add(-time.Hour) - checked, earned, err := earning.EarnMissing(ctx, since, 1000) - require.NoError(t, err) - assert.GreaterOrEqual(t, earned, 1) - assert.GreaterOrEqual(t, checked, earned) - assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed)) - - // The job only looks at orders that could earn and have not. - candidates, err := repository.NewEarningRepository(db).ListPaidOrdersWithoutEarning(ctx, since, nil, 1000) - require.NoError(t, err) - var ours []uuid.UUID - for _, c := range candidates { - var n int64 - db.Raw(`SELECT COUNT(*) FROM orders WHERE id = ? AND organization_id = ?`, c.ID, org).Scan(&n) - if n > 0 { - ours = append(ours, c.ID) - } - } - assert.Empty(t, ours, "every eligible order of ours has earned; walk-in, inactive, void, unpaid and non-earning outlets are never candidates") - - // A second run finds nothing more to do for these orders. - _, _, err = earning.EarnMissing(ctx, since, 1000) - require.NoError(t, err) - assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed)) - - // What the order screen and receipt show is what the ledger has. - shown, err := earning.EarnedByOrders(ctx, []uuid.UUID{paid, split, missed, uuid.New()}) - require.NoError(t, err) - assert.Equal(t, OrderEarned{Points: 875, Coins: 3}, shown[paid]) - assert.Equal(t, OrderEarned{Points: 600, Coins: 2}, shown[split]) - assert.Equal(t, OrderEarned{Points: 400, Coins: 1}, shown[missed]) - assert.Len(t, shown, 3, "an order without earning is absent, so it shows 0") - - balance := struct{ PointBalance, CoinBalance int64 }{} - require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, regular).Scan(&balance).Error) - assert.Equal(t, int64(875+250+600+400), balance.PointBalance) - assert.Equal(t, int64(3+1+2+1), balance.CoinBalance) -} - -func (f *failingSettings) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) { - return f.real.Organization(ctx, organizationID) -} diff --git a/internal/processor/earning_reversal.go b/internal/processor/earning_reversal.go deleted file mode 100644 index e0dc1dd..0000000 --- a/internal/processor/earning_reversal.go +++ /dev/null @@ -1,167 +0,0 @@ -package processor - -import ( - "context" - "fmt" - - "github.com/google/uuid" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/repository" -) - -// ReversalOutcome is what a reversal took back, and what it could not. -type ReversalOutcome struct { - Points int64 - Coins int64 - PointShortfall int64 - CoinShortfall int64 -} - -// OnOrderRefunded is called after an order was voided or (partly) refunded and that -// has committed. It never fails the caller: a refund is never blocked by the loyalty -// balance (Q3), so errors are logged. -func (p *EarningProcessor) OnOrderRefunded(ctx context.Context, orderID uuid.UUID) { - defer func() { - if r := recover(); r != nil { - logger.NonContext.Error(fmt.Sprintf("Earning reversal for order %s panicked", orderID), fmt.Errorf("%v", r)) - } - }() - if _, err := p.ReverseForOrder(ctx, orderID); err != nil { - logger.NonContext.Error(fmt.Sprintf("Earning reversal for order %s failed", orderID), err) - } -} - -// ReverseForOrder takes back what an order earned, as far as it has been voided or -// refunded (docs/prd-point-coin.md F10): -// -// - void: everything the order earned; -// - refund: floor(earned × refunded / basis), never more than was earned, with the -// refunded amount being the order's cumulative refund. -// -// Only the part not asked back yet is taken, so calling it again, or after each of -// several partial refunds, never takes more than the order earned. It draws from the -// lots the EARN created first, then from the others in K9 order, and takes what the -// balance has when it is short, recording the rest as shortfall (Q3). -func (p *EarningProcessor) ReverseForOrder(ctx context.Context, orderID uuid.UUID) (*ReversalOutcome, error) { - order, err := p.orders.GetOrderForEarning(ctx, orderID) - if err != nil { - return nil, err - } - earns, err := p.orders.ListEarnTransactions(ctx, orderID) - if err != nil { - return nil, err - } - outcome := &ReversalOutcome{} - if len(earns) == 0 { - return outcome, nil - } - - err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { - for _, earn := range earns { - target := earningReversalTarget(order, earn) - requested, err := p.orders.ReversalRequested(ctx, earn.ID) - if err != nil { - return err - } - amount := target - requested - if amount <= 0 { - continue - } - lots, err := p.orders.LotIDsCreatedBy(ctx, earn.ID) - if err != nil { - return err - } - - reason := "REFUND" - if order.IsVoid { - reason = "VOID" - } - earnID := earn.ID - res, err := p.wallet.DebitUpTo(ctx, WalletDebitInput{ - WalletEntry: WalletEntry{ - CustomerID: earn.CustomerID, - Currency: earn.Currency, - Type: constants.WalletTxTypeEarnReversal, - Amount: amount, - ReferenceType: constants.WalletRefTypeOrder, - ReferenceID: order.ID, - ReversesTransactionID: &earnID, - OutletID: earn.OutletID, - Description: earningReversalDescription(order), - Metadata: entities.Metadata{ - "reason": reason, - "refund_amount": order.RefundAmount, - "target": target, - }, - // The target only grows with each refund, so each refund gets its own - // key while a retry of the same one replays. - IdempotencyKey: fmt.Sprintf("reverse:%s:%d", earn.ID, target), - }, - PreferredLotIDs: lots, - }) - if err != nil { - return fmt.Errorf("reversing %s: %w", earn.Currency, err) - } - - var taken int64 - if res.Transaction != nil { - taken = -res.Transaction.Amount - } else { - // Nothing to take: the ledger has no row to carry the shortfall. - logger.NonContext.WarnWithFields("Earning reversal found an empty balance; the whole amount is shortfall", map[string]interface{}{ - "order_id": order.ID.String(), "customer_id": earn.CustomerID.String(), - "currency": earn.Currency, "shortfall": res.Shortfall, - }, nil) - } - switch earn.Currency { - case constants.WalletCurrencyPoint: - outcome.Points += taken - outcome.PointShortfall += res.Shortfall - case constants.WalletCurrencyCoin: - outcome.Coins += taken - outcome.CoinShortfall += res.Shortfall - } - } - return nil - }) - if err != nil { - return nil, err - } - return outcome, nil -} - -// earningReversalTarget is how much of an EARN row should have been taken back in -// total, given the order's void flag and cumulative refund. It works in cents like -// CalculateEarning, with the basis frozen on the EARN row. -func earningReversalTarget(order *repository.EarningOrder, earn entities.WalletTransaction) int64 { - earned := earn.Amount - if order.IsVoid { - return earned - } - refundCents := toCents(order.RefundAmount) - if refundCents <= 0 { - return 0 - } - basis, _ := earn.Metadata["basis"].(float64) - basisCents := toCents(basis) - // A refund can include tax, which the basis does not, so it can reach past it. - if basisCents <= 0 || refundCents >= basisCents { - return earned - } - return earned * refundCents / basisCents -} - -func earningReversalDescription(order *repository.EarningOrder) string { - verb := "Refund" - if order.IsVoid { - verb = "Batal" - } - description := verb + " #" + order.OrderNumber - if order.OutletName != "" { - description += " di " + order.OutletName - } - return truncateRunes(description, walletDescriptionLimit) -} diff --git a/internal/processor/earning_reversal_db_test.go b/internal/processor/earning_reversal_db_test.go deleted file mode 100644 index d17dd8e..0000000 --- a/internal/processor/earning_reversal_db_test.go +++ /dev/null @@ -1,206 +0,0 @@ -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. -func TestEarningReversal_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, user, outlet := uuid.New(), uuid.New(), uuid.New() - voider, refunder, spender, loyal := uuid.New(), uuid.New(), uuid.New(), uuid.New() - customers := []uuid.UUID{voider, refunder, spender, loyal} - 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 (?, 'reversal test', 'basic')`, org) - exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, user, org, user.String()+"@test") - exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org) - for _, c := range customers { - exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, 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 orders WHERE organization_id = ?`, org) - db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org) - db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet) - db.Exec(`DELETE FROM customers WHERE id IN ?`, customers) - db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet) - db.Exec(`DELETE FROM users WHERE id = ?`, user) - db.Exec(`DELETE FROM organizations WHERE id = ?`, org) - }) - - txm := repository.NewTxManager(db) - settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) - s, err := settings.Outlet(ctx, outlet) - require.NoError(t, err) - s.Point.Enabled, s.Coin.Enabled = true, true - _, err = settings.UpdateOutlet(ctx, org, outlet, user, *s) - require.NoError(t, err) - wallet := NewWalletProcessor(repository.NewWalletRepository(db)) - earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm) - - // Each order is the PRD example: basis Rp 87.500, 875 EnakPoint and 3 EnakCoin. - paidOrder := func(customer uuid.UUID) uuid.UUID { - t.Helper() - id := uuid.New() - exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type, - subtotal, discount_amount, tax_amount, total_amount, payment_status) - VALUES (?, ?, ?, ?, ?, ?, 'dine_in', 97500, 10000, 8750, 96250, 'completed')`, - id, org, outlet, user, customer, "ORD-"+id.String()[:8]) - outcome, err := earning.EarnForOrder(ctx, id) - require.NoError(t, err) - require.Equal(t, int64(875), outcome.Points) - return id - } - balance := func(customer uuid.UUID) (points, coins int64) { - t.Helper() - var b struct{ PointBalance, CoinBalance int64 } - require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error) - return b.PointBalance, b.CoinBalance - } - reversed := func(order uuid.UUID, currency string) int64 { - t.Helper() - var total int64 - require.NoError(t, db.Raw(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions - WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = ?`, order, currency).Scan(&total).Error) - return total - } - - // Void: everything comes back. - voided := paidOrder(voider) - exec(`UPDATE orders SET is_void = true WHERE id = ?`, voided) - earning.OnOrderRefunded(ctx, voided) - assert.Equal(t, int64(875), reversed(voided, "POINT")) - assert.Equal(t, int64(3), reversed(voided, "COIN")) - p, c := balance(voider) - assert.Equal(t, [2]int64{0, 0}, [2]int64{p, c}) - - var rev struct { - ReversesTransactionID string - Description string - } - require.NoError(t, db.Raw(`SELECT reverses_transaction_id::text AS reverses_transaction_id, description FROM wallet_transactions - WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'`, voided).Scan(&rev).Error) - var earnID string - require.NoError(t, db.Raw(`SELECT id::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN' AND currency = 'POINT'`, voided).Scan(&earnID).Error) - assert.Equal(t, earnID, rev.ReversesTransactionID, "the reversal points at the EARN it reverses") - assert.Contains(t, rev.Description, "Batal #ORD-") - - // Repeating it takes nothing more. - earning.OnOrderRefunded(ctx, voided) - assert.Equal(t, int64(875), reversed(voided, "POINT")) - - // Partial refunds are proportional and add up to at most what was earned. - refunded := paidOrder(refunder) - for _, step := range []struct { - refundAmount float64 - wantTotal int64 - }{ - {20000, 200}, // floor(875 × 20000 / 87500) - {20000, 200}, // same refund again: nothing more - {50000, 500}, // cumulative refund grew - {96250, 875}, // the whole total, tax included, cannot exceed the earning - {96250, 875}, - } { - exec(`UPDATE orders SET refund_amount = ? WHERE id = ?`, step.refundAmount, refunded) - outcome, err := earning.ReverseForOrder(ctx, refunded) - require.NoError(t, err) - assert.Zero(t, outcome.PointShortfall) - assert.Equal(t, step.wantTotal, reversed(refunded, "POINT"), "after a refund of %.0f", step.refundAmount) - } - assert.Equal(t, int64(3), reversed(refunded, "COIN")) - - // Balance already spent: what is left is taken, the rest recorded as shortfall, - // and the refund is not blocked. - spent := paidOrder(spender) - committed(t, txm, func(ctx context.Context) (*WalletResult, error) { - return wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ - CustomerID: spender, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, - Amount: 800, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet, - Description: "Bayar"}}) - }) - exec(`UPDATE orders SET is_void = true WHERE id = ?`, spent) - outcome, err := earning.ReverseForOrder(ctx, spent) - require.NoError(t, err) - assert.Equal(t, int64(75), outcome.Points) - assert.Equal(t, int64(800), outcome.PointShortfall) - p, _ = balance(spender) - assert.Equal(t, int64(0), p, "never negative") - var metadata string - require.NoError(t, db.Raw(`SELECT metadata::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'`, spent).Scan(&metadata).Error) - assert.Contains(t, metadata, `"shortfall": 800`) - assert.Contains(t, metadata, `"requested_amount": 875`) - // Asked back in full already, shortfall included: a repeat takes nothing. - outcome, err = earning.ReverseForOrder(ctx, spent) - require.NoError(t, err) - assert.Zero(t, outcome.Points) - - // The EARN's own lots are drawn first, even when an older lot would come first. - older := committed(t, txm, func(ctx context.Context) (*WalletResult, error) { - return wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{ - CustomerID: loyal, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, - Amount: 500, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}}) - }) - loyalOrder := paidOrder(loyal) - exec(`UPDATE orders SET refund_amount = 20000 WHERE id = ?`, loyalOrder) - _, err = earning.ReverseForOrder(ctx, loyalOrder) - require.NoError(t, err) - var fromOlder int64 - require.NoError(t, db.Raw(`SELECT COALESCE(SUM(a.amount), 0) FROM wallet_lot_allocations a - JOIN wallet_transactions t ON t.id = a.transaction_id - WHERE t.reference_id = ? AND t.type = 'EARN_REVERSAL' AND a.lot_id = ?`, loyalOrder, older.Lots[0].ID).Scan(&fromOlder).Error) - assert.Zero(t, fromOlder, "the older lot is untouched") - assert.Equal(t, int64(200), reversed(loyalOrder, "POINT")) - - // An order that never earned has nothing to reverse. - outcome, err = earning.ReverseForOrder(ctx, uuid.New()) - assert.ErrorIs(t, err, repository.ErrEarningOrderNotFound) - assert.Nil(t, outcome) - - // Every reversal kept balances, ledger and lots in step (§7.5). - found, err := repository.NewWalletReconciliationRepository(db).FindDiscrepancies(ctx, 1000) - require.NoError(t, err) - for _, d := range found { - for _, c := range customers { - assert.NotEqual(t, c, d.CustomerID, "discrepancy %s for one of our customers", d.Check) - } - } -} - -// committed runs fn in a transaction that commits before it returns, for engine calls -// made directly from a test. -func committed[T any](t *testing.T, txm *repository.TxManager, fn func(ctx context.Context) (T, error)) T { - t.Helper() - var out T - require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { - var err error - out, err = fn(ctx) - return err - })) - return out -} diff --git a/internal/processor/earning_reversal_test.go b/internal/processor/earning_reversal_test.go deleted file mode 100644 index 760fae4..0000000 --- a/internal/processor/earning_reversal_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package processor - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/repository" -) - -func TestEarningReversalTarget(t *testing.T) { - earn := entities.WalletTransaction{Amount: 875, Metadata: entities.Metadata{"basis": 87500.0}} - target := func(order repository.EarningOrder) int64 { return earningReversalTarget(&order, earn) } - - assert.Equal(t, int64(0), target(repository.EarningOrder{}), "nothing refunded, nothing to take") - assert.Equal(t, int64(875), target(repository.EarningOrder{IsVoid: true}), "void takes everything") - assert.Equal(t, int64(200), target(repository.EarningOrder{RefundAmount: 20000}), "floor(875 × 20000 / 87500)") - assert.Equal(t, int64(299), target(repository.EarningOrder{RefundAmount: 29999.99}), "floor, not round") - assert.Equal(t, int64(875), target(repository.EarningOrder{RefundAmount: 87500})) - assert.Equal(t, int64(875), target(repository.EarningOrder{RefundAmount: 96250}), "a refund including tax cannot take more than was earned") - - noBasis := entities.WalletTransaction{Amount: 10, Metadata: entities.Metadata{}} - assert.Equal(t, int64(10), earningReversalTarget(&repository.EarningOrder{RefundAmount: 1}, noBasis), "without a basis any refund takes it all") -} diff --git a/internal/processor/game_play_processor.go b/internal/processor/game_play_processor.go index 15e3337..4a77282 100644 --- a/internal/processor/game_play_processor.go +++ b/internal/processor/game_play_processor.go @@ -1,7 +1,6 @@ package processor import ( - "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" @@ -9,55 +8,33 @@ import ( "context" "errors" "fmt" - "math" "math/rand" "time" "github.com/google/uuid" ) -// ErrGamePlayRefused wraps every reason a customer cannot play right now: the game, -// its prizes or their EnakCoin. The message says which. -var ErrGamePlayRefused = errors.New("game cannot be played") - -const ( - gameCoinCostKey = "coin_cost" - defaultGameCoinCost = 1 -) - -type gameReader interface { - GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error) -} - -type gamePrizeStock interface { - GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) - DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error -} - type GamePlayProcessor struct { - gamePlayRepo repository.GamePlayRepository - gameRepo gameReader - gamePrizeRepo gamePrizeStock - wallet *WalletProcessor - spendable spendableReader - tx TxRunner + gamePlayRepo repository.GamePlayRepository + gameRepo *repository.GameRepository + gamePrizeRepo *repository.GamePrizeRepository + customerTokensRepo *repository.CustomerTokensRepository + customerPointsRepo repository.CustomerPointsRepository } func NewGamePlayProcessor( gamePlayRepo repository.GamePlayRepository, - gameRepo gameReader, - gamePrizeRepo gamePrizeStock, - wallet *WalletProcessor, - spendable spendableReader, - tx TxRunner, + gameRepo *repository.GameRepository, + gamePrizeRepo *repository.GamePrizeRepository, + customerTokensRepo *repository.CustomerTokensRepository, + customerPointsRepo repository.CustomerPointsRepository, ) *GamePlayProcessor { return &GamePlayProcessor{ - gamePlayRepo: gamePlayRepo, - gameRepo: gameRepo, - gamePrizeRepo: gamePrizeRepo, - wallet: wallet, - spendable: spendable, - tx: tx, + gamePlayRepo: gamePlayRepo, + gameRepo: gameRepo, + gamePrizeRepo: gamePrizeRepo, + customerTokensRepo: customerTokensRepo, + customerPointsRepo: customerPointsRepo, } } @@ -99,10 +76,6 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis } offset := (query.Page - 1) * query.Limit - // The column was renamed; the old name still sorts the same way. - if query.SortBy == "token_used" { - query.SortBy = "coins_used" - } // Get game plays from repository gamePlays, total, err := p.gamePlayRepo.List( @@ -137,140 +110,101 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis }, nil } -// PlayGame plays a game for a customer, paid with EnakCoin (docs/prd-point-coin.md -// F8, K1). Every kind of game spends the same EnakCoin, at the game's -// metadata.coin_cost. Taking the EnakCoin (GAME_SPEND), recording the play and taking -// the prize from stock happen in one transaction, so a play that cannot be completed, -// for instance because the prize just ran out, takes nothing and records nothing. +// PlayGame handles the game playing logic func (p *GamePlayProcessor) PlayGame(ctx context.Context, req *models.PlayGameRequest) (*models.PlayGameResponse, error) { - refuse := func(format string, args ...any) error { - return fmt.Errorf("%w: %s", ErrGamePlayRefused, fmt.Sprintf(format, args...)) - } + // Verify game exists and is active game, err := p.gameRepo.GetByID(ctx, req.GameID) if err != nil { return nil, fmt.Errorf("game not found: %w", err) } + if !game.IsActive { - return nil, refuse("game is not active") - } - cost, err := gameCoinCost(game.Metadata) - if err != nil { - return nil, refuse("%s", err.Error()) + return nil, errors.New("game is not active") } - var gamePlay *entities.GamePlay - var selectedPrize *models.GamePrizeResponse - err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { - availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID) + // Convert GameType to TokenType + tokenType := entities.TokenType(game.Type) + + // Check if customer has enough tokens + customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType) + if err != nil { + return nil, fmt.Errorf("customer tokens not found: %w", err) + } + + if customerTokens.Balance < int64(req.TokenUsed) { + return nil, errors.New("insufficient tokens") + } + + // Deduct tokens + err = p.customerTokensRepo.DeductTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed)) + if err != nil { + return nil, fmt.Errorf("failed to deduct tokens: %w", err) + } + + // Get available prizes + availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID) + if err != nil { + return nil, fmt.Errorf("failed to get available prizes: %w", err) + } + + if len(availablePrizes) == 0 { + return nil, errors.New("no prizes available") + } + + // Convert entities to models for prize selection + prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes)) + for i, prize := range availablePrizes { + prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize) + } + + // Select prize based on weight + selectedPrize := p.selectPrizeByWeight(prizeResponses) + + // Generate random seed for audit + randomSeed := fmt.Sprintf("%d", time.Now().UnixNano()) + + // Create game play record + gamePlay := &models.CreateGamePlayRequest{ + GameID: req.GameID, + CustomerID: req.CustomerID, + TokenUsed: req.TokenUsed, + RandomSeed: &randomSeed, + } + + gamePlayEntity := mappers.ToGamePlayEntity(gamePlay) + if selectedPrize != nil { + gamePlayEntity.PrizeID = &selectedPrize.ID + } + + err = p.gamePlayRepo.Create(ctx, gamePlayEntity) + if err != nil { + // Rollback token deduction + p.customerTokensRepo.AddTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed)) + return nil, fmt.Errorf("failed to create game play: %w", err) + } + + // Decrease prize stock if prize was won + if selectedPrize != nil { + err = p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1) if err != nil { - return fmt.Errorf("failed to get available prizes: %w", err) + // Log error but don't fail the transaction + fmt.Printf("Warning: failed to decrease prize stock: %v\n", err) } - if len(availablePrizes) == 0 { - return refuse("no prizes available") - } - prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes)) - for i, prize := range availablePrizes { - prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize) - } - selectedPrize = p.selectPrizeByWeight(prizeResponses) - - // Random seed kept for audit. - randomSeed := fmt.Sprintf("%d", time.Now().UnixNano()) - gamePlay = &entities.GamePlay{ - ID: uuid.New(), - GameID: req.GameID, - CustomerID: req.CustomerID, - CoinsUsed: int(cost), - RandomSeed: &randomSeed, - } - if selectedPrize != nil { - gamePlay.PrizeID = &selectedPrize.ID - } - - // The EnakCoin go first: the debit locks the wallet, so plays by the same - // customer at the same time queue up instead of spending the same balance. - _, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ - CustomerID: req.CustomerID, - Currency: constants.WalletCurrencyCoin, - Type: constants.WalletTxTypeGameSpend, - Amount: cost, - ReferenceType: constants.WalletRefTypeGamePlay, - ReferenceID: gamePlay.ID, - Description: gameSpendDescription(game.Name, selectedPrize), - Metadata: entities.Metadata{"game_id": game.ID.String(), "coin_cost": cost}, - }}) - if errors.Is(err, repository.ErrWalletInsufficientBalance) { - return refuse("not enough EnakCoin: this game costs %d", cost) - } - if err != nil { - return err - } - if err := p.gamePlayRepo.Create(ctx, gamePlay); err != nil { - return err - } - if selectedPrize != nil { - err := p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1) - if errors.Is(err, repository.ErrGamePrizeOutOfStock) { - return refuse("the prize just ran out, please play again") - } - if err != nil { - return err - } - } - return nil - }) - if err != nil { - return nil, err } - balances, err := p.spendable.SpendableBalances(ctx, req.CustomerID, time.Now()) + // Get updated token balance + updatedTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get updated token balance: %w", err) } + return &models.PlayGameResponse{ - GamePlay: *mappers.ToGamePlayResponse(gamePlay), - PrizeWon: selectedPrize, - CoinsRemaining: balances[constants.WalletCurrencyCoin], + GamePlay: *mappers.ToGamePlayResponse(gamePlayEntity), + PrizeWon: selectedPrize, + TokensRemaining: updatedTokens.Balance, }, nil } -// gameCoinCost is what one play of a game costs, from games.metadata.coin_cost: a -// whole number of EnakCoin, 1 when it is not set (F8). -func gameCoinCost(metadata entities.Metadata) (int64, error) { - raw, ok := metadata[gameCoinCostKey] - if !ok || raw == nil { - return defaultGameCoinCost, nil - } - var cost int64 - switch v := raw.(type) { - case float64: - if v != math.Trunc(v) { - return 0, fmt.Errorf("the game's coin_cost %v is not a whole number", v) - } - cost = int64(v) - case int: - cost = int64(v) - case int64: - cost = v - default: - return 0, fmt.Errorf("the game's coin_cost %v is not a number", raw) - } - if cost < 1 { - return 0, fmt.Errorf("the game's coin_cost must be at least 1, got %d", cost) - } - return cost, nil -} - -// gameSpendDescription is what the customer's history shows for a play, frozen when it -// happens (§8.1): "Main Spin Wheel: dapat Voucher 10rb". -func gameSpendDescription(gameName string, prize *models.GamePrizeResponse) string { - description := "Main " + gameName - if prize != nil { - description += ": dapat " + prize.Name - } - return truncateRunes(description, walletDescriptionLimit) -} - // selectPrizeByWeight selects a prize based on weight distribution func (p *GamePlayProcessor) selectPrizeByWeight(prizes []models.GamePrizeResponse) *models.GamePrizeResponse { if len(prizes) == 0 { diff --git a/internal/processor/game_play_processor_db_test.go b/internal/processor/game_play_processor_db_test.go deleted file mode 100644 index ef1cb6d..0000000 --- a/internal/processor/game_play_processor_db_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package processor - -import ( - "context" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// Plays against Postgres, so the game repositories really join the transaction: a -// refused play leaves no game_plays row and no GAME_SPEND. -func TestPlayGame_AgainstPostgres(t *testing.T) { - db, _, player, _ := walletMoveDB(t) - gameID, prizeID := uuid.New(), uuid.New() - require.NoError(t, db.Exec(`INSERT INTO games (id, name, type, metadata) VALUES (?, 'Raffle', 'RAFFLE', '{"coin_cost": 2}')`, gameID).Error) - require.NoError(t, db.Exec(`INSERT INTO game_prizes (id, game_id, name, weight, stock) VALUES (?, ?, 'Tumbler', 1, 1)`, prizeID, gameID).Error) - t.Cleanup(func() { - db.Exec(`DELETE FROM wallet_lot_allocations WHERE transaction_id IN (SELECT id FROM wallet_transactions WHERE customer_id = ?)`, player) - db.Exec(`DELETE FROM game_plays WHERE game_id = ?`, gameID) - db.Exec(`DELETE FROM game_prizes WHERE game_id = ?`, gameID) - db.Exec(`DELETE FROM games WHERE id = ?`, gameID) - }) - - wallet := NewWalletProcessor(repository.NewWalletRepository(db)) - txm := repository.NewTxManager(db) - require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { - in := earn(player, 3, nil) - in.Currency = constants.WalletCurrencyCoin - _, err := wallet.Credit(ctx, in) - return err - })) - p := NewGamePlayProcessor(repository.NewGamePlayRepository(db), repository.NewGameRepository(db), repository.NewGamePrizeRepository(db), - wallet, repository.NewWalletQueryRepository(db), txm) - play := func() (*models.PlayGameResponse, error) { - return p.PlayGame(context.Background(), &models.PlayGameRequest{GameID: gameID, CustomerID: player}) - } - - res, err := play() - require.NoError(t, err) - assert.Equal(t, int64(1), res.CoinsRemaining) - assert.Equal(t, 2, res.GamePlay.CoinsUsed) - - var spend struct { - ReferenceID uuid.UUID - Amount int64 - } - require.NoError(t, db.Raw(`SELECT reference_id, amount FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spend).Error) - assert.Equal(t, res.GamePlay.ID, spend.ReferenceID) - assert.Equal(t, int64(-2), spend.Amount) - - // The only prize is gone, then the EnakCoin are too short: neither play leaves a row. - _, err = play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - require.NoError(t, db.Exec(`UPDATE game_prizes SET stock = 5 WHERE id = ?`, prizeID).Error) - _, err = play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - - var plays, spends int64 - require.NoError(t, db.Raw(`SELECT COUNT(*) FROM game_plays WHERE game_id = ? AND coins_used = 2`, gameID).Scan(&plays).Error) - require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spends).Error) - assert.Equal(t, int64(1), plays) - assert.Equal(t, int64(1), spends) -} diff --git a/internal/processor/game_play_processor_test.go b/internal/processor/game_play_processor_test.go deleted file mode 100644 index 6a43670..0000000 --- a/internal/processor/game_play_processor_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package processor - -import ( - "context" - "errors" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// snapshot saves everything the fake wallet holds and returns what puts it back, so -// a test transaction can roll back like a real one. -func (f *walletRepoFake) snapshot() func() { - wallets := make(map[uuid.UUID]*entities.CustomerWallet, len(f.wallets)) - for id, w := range f.wallets { - c := *w - wallets[id] = &c - } - transactions := make([]*entities.WalletTransaction, len(f.transactions)) - for i, t := range f.transactions { - c := *t - transactions[i] = &c - } - lots := make([]*entities.WalletLot, len(f.lots)) - for i, l := range f.lots { - c := *l - lots[i] = &c - } - allocations := append([]entities.WalletLotAllocation(nil), f.allocations...) - return func() { - f.wallets, f.transactions, f.lots, f.allocations = wallets, transactions, lots, allocations - } -} - -// rollbackTx runs fn in place and, when it fails, puts back what each save saved. -type rollbackTx struct{ saves []func() func() } - -func (r rollbackTx) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error { - restores := make([]func(), 0, len(r.saves)) - for _, save := range r.saves { - restores = append(restores, save()) - } - err := fn(ctx) - if err != nil { - for _, restore := range restores { - restore() - } - } - return err -} - -type gameFake struct{ games map[uuid.UUID]*entities.Game } - -func (f gameFake) GetByID(_ context.Context, id uuid.UUID) (*entities.Game, error) { - g, ok := f.games[id] - if !ok { - return nil, errors.New("record not found") - } - return g, nil -} - -type prizeStockFake struct { - prizes []entities.GamePrize - stockErr error -} - -func (f *prizeStockFake) GetAvailablePrizes(_ context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) { - var out []entities.GamePrize - for _, p := range f.prizes { - if p.GameID == gameID && p.Stock > 0 { - out = append(out, p) - } - } - return out, nil -} - -func (f *prizeStockFake) DecreaseStock(_ context.Context, id uuid.UUID, amount int) error { - if f.stockErr != nil { - return f.stockErr - } - for i := range f.prizes { - if f.prizes[i].ID == id { - if f.prizes[i].Stock < amount { - return repository.ErrGamePrizeOutOfStock - } - f.prizes[i].Stock -= amount - return nil - } - } - return repository.ErrGamePrizeOutOfStock -} - -func (f *prizeStockFake) snapshot() func() { - prizes := append([]entities.GamePrize(nil), f.prizes...) - return func() { f.prizes = prizes } -} - -// gamePlayRepoFake records plays; the other methods of the interface are not used. -type gamePlayRepoFake struct { - repository.GamePlayRepository - plays []entities.GamePlay -} - -func (f *gamePlayRepoFake) Create(_ context.Context, gp *entities.GamePlay) error { - f.plays = append(f.plays, *gp) - return nil -} - -func (f *gamePlayRepoFake) snapshot() func() { - plays := append([]entities.GamePlay(nil), f.plays...) - return func() { f.plays = plays } -} - -type gameTestEnv struct { - *walletMoveEnv - game *entities.Game - prize entities.GamePrize - stock *prizeStockFake - plays *gamePlayRepoFake - player uuid.UUID - p *GamePlayProcessor -} - -func newGameTestEnv(t *testing.T, metadata entities.Metadata) *gameTestEnv { - e := &gameTestEnv{walletMoveEnv: newWalletMoveEnv(t)} - e.game = &entities.Game{ID: uuid.New(), Name: "Spin Wheel", Type: entities.GameTypeSpin, IsActive: true, Metadata: metadata} - e.prize = entities.GamePrize{ID: uuid.New(), GameID: e.game.ID, Name: "Voucher 10rb", Weight: 1, Stock: 5} - e.stock = &prizeStockFake{prizes: []entities.GamePrize{e.prize}} - e.plays = &gamePlayRepoFake{} - e.player = e.member("Budi", "081234561234") - tx := rollbackTx{saves: []func() func(){e.repo.snapshot, e.stock.snapshot, e.plays.snapshot}} - e.p = NewGamePlayProcessor(e.plays, gameFake{games: map[uuid.UUID]*entities.Game{e.game.ID: e.game}}, e.stock, e.walletTestEnv.p, e, tx) - return e -} - -func (e *gameTestEnv) play() (*models.PlayGameResponse, error) { - return e.p.PlayGame(e.ctx, &models.PlayGameRequest{GameID: e.game.ID, CustomerID: e.player}) -} - -func TestPlayGame_SpendsTheGamesCoinCost(t *testing.T) { - // coin_cost comes back from JSONB as a float64. - e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)}) - e.earnCoins(t, e.player, 10, nil) - - res, err := e.play() - require.NoError(t, err) - - assert.Equal(t, int64(7), res.CoinsRemaining) - require.NotNil(t, res.PrizeWon) - assert.Equal(t, e.prize.ID, res.PrizeWon.ID) - require.Len(t, e.plays.plays, 1) - play := e.plays.plays[0] - assert.Equal(t, 3, play.CoinsUsed) - assert.Equal(t, e.prize.ID, *play.PrizeID) - assert.Equal(t, 4, e.stock.prizes[0].Stock) - - spend := e.repo.transactions[len(e.repo.transactions)-1] - assert.Equal(t, constants.WalletTxTypeGameSpend, spend.Type) - assert.Equal(t, constants.WalletCurrencyCoin, spend.Currency) - assert.Equal(t, int64(-3), spend.Amount) - assert.Equal(t, constants.WalletRefTypeGamePlay, spend.ReferenceType) - assert.Equal(t, play.ID, spend.ReferenceID, "GAME_SPEND points at the game play") - assert.Equal(t, "Main Spin Wheel: dapat Voucher 10rb", spend.Description) -} - -func TestPlayGame_CostsOneCoinByDefault(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{}) - e.earnCoins(t, e.player, 2, nil) - - res, err := e.play() - require.NoError(t, err) - assert.Equal(t, int64(1), res.CoinsRemaining) - assert.Equal(t, 1, e.plays.plays[0].CoinsUsed) -} - -func TestPlayGame_NotEnoughCoinsRecordsNothing(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)}) - e.earnCoins(t, e.player, 2, nil) - - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - assert.Empty(t, e.plays.plays) - assert.Equal(t, 5, e.stock.prizes[0].Stock) - assert.Equal(t, int64(2), e.coinBalance(t, e.player)) -} - -func TestPlayGame_PrizeStockFailureCancelsThePlay(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{}) - e.earnCoins(t, e.player, 5, nil) - rows := len(e.repo.transactions) - - // Another play took the last one between reading the prizes and taking it. - e.stock.stockErr = repository.ErrGamePrizeOutOfStock - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - - // Anything else going wrong with the stock cancels it too. - e.stock.stockErr = errors.New("connection reset") - _, err = e.play() - assert.Error(t, err) - - assert.Empty(t, e.plays.plays) - assert.Len(t, e.repo.transactions, rows, "no GAME_SPEND is left behind") - assert.Equal(t, int64(5), e.coinBalance(t, e.player)) -} - -func TestPlayGame_RefusesWhatCannotBePlayed(t *testing.T) { - for name, metadata := range map[string]entities.Metadata{ - "zero": {"coin_cost": float64(0)}, - "fraction": {"coin_cost": 1.5}, - "not number": {"coin_cost": "2"}, - } { - t.Run(name, func(t *testing.T) { - e := newGameTestEnv(t, metadata) - e.earnCoins(t, e.player, 5, nil) - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - assert.Equal(t, int64(5), e.coinBalance(t, e.player)) - }) - } - - t.Run("inactive", func(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{}) - e.earnCoins(t, e.player, 5, nil) - e.game.IsActive = false - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - }) - - t.Run("no prizes left", func(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{}) - e.earnCoins(t, e.player, 5, nil) - e.stock.prizes[0].Stock = 0 - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - assert.Equal(t, int64(5), e.coinBalance(t, e.player)) - }) -} diff --git a/internal/processor/loyalty_expiry.go b/internal/processor/loyalty_expiry.go deleted file mode 100644 index cf05322..0000000 --- a/internal/processor/loyalty_expiry.go +++ /dev/null @@ -1,170 +0,0 @@ -package processor - -import ( - "errors" - "fmt" - "sort" - "strings" - "time" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/models" -) - -// Expiry rules of docs/prd-point-coin.md F12, with the model of note N4 decided: both -// FIXED_DATE and ROLLING are supported and the owner picks one per currency. -// -// A lot expires at the last second of its expiry day in the customer's time zone, so -// "expires on 31 Dec" means it can still be spent all of 31 Dec, and the day the apps -// group it under is that day. - -const ( - // A longer grace would push a balance past a whole cycle of fixed dates. - loyaltyExpiryMaxGraceMonths = 24 - // A refund gives the EnakPoint back at least this long, even when the lot they - // came from has expired or is about to (note N4, decided). - refundMinimumValidity = 7 * 24 * time.Hour -) - -// ExpirySettings picks the settings of one currency. -func ExpirySettings(s *models.OrganizationLoyaltySettings, currency string) models.LoyaltyExpirySettings { - if currency == constants.WalletCurrencyCoin { - return s.CoinExpiry - } - return s.PointExpiry -} - -// ComputeExpiry is when a balance received at receivedAt expires, or nil when it never -// does because expiry is off. It is the expiry of every lot that is not carried over -// from another one: EARN, ADJUSTMENT and, capped by its origin, EXCHANGE_IN. -func ComputeExpiry(s models.LoyaltyExpirySettings, receivedAt time.Time) *time.Time { - if !s.Enabled { - return nil - } - day := walletDay(receivedAt) - if s.Mode == constants.LoyaltyExpiryModeRolling { - if s.Unit == constants.LoyaltyExpiryUnitDay { - day = day.AddDate(0, 0, int(s.Period)) - } else { - day = addMonthsClamped(day, int(s.Period)) - } - if s.EndOfMonth { - day = lastDayOfMonth(day) - } - return endOfWalletDay(day) - } - return endOfWalletDay(fixedDateOnOrAfter(s.FixedDates, addMonthsClamped(day, int(s.GraceMonths)))) -} - -// ActivationExpiry is the expiry given to the balances that already exist, without an -// expiry, when expiry is turned on (note N4, decided): a full period from activation -// when ROLLING, the second fixed date after activation when FIXED_DATE. Either way no -// customer loses a balance soon after the rule is announced. -func ActivationExpiry(s models.LoyaltyExpirySettings, at time.Time) *time.Time { - if s.Mode == constants.LoyaltyExpiryModeRolling { - s.Enabled = true - return ComputeExpiry(s, at) - } - first := fixedDateOnOrAfter(s.FixedDates, walletDay(at)) - return endOfWalletDay(fixedDateOnOrAfter(s.FixedDates, first.AddDate(0, 0, 1))) -} - -// RefundExpiry is the expiry of EnakPoint given back by a refund: that of the lot they -// came from, but at least seven days from now (note N4, decided). A lot that never -// expires stays so. -func RefundExpiry(origin *time.Time, now time.Time) *time.Time { - if origin == nil { - return nil - } - minimum := endOfWalletDay(walletDay(now.Add(refundMinimumValidity))) - if origin.Before(*minimum) { - return minimum - } - return origin -} - -// EarlierExpiry is the sooner of two expiries, where nil means never. -func EarlierExpiry(a, b *time.Time) *time.Time { - switch { - case a == nil: - return b - case b == nil: - return a - case b.Before(*a): - return b - } - return a -} - -// normalizeDaysOfYear checks days written as MM-DD and returns them sorted without -// duplicates. 29 February is refused, as it does not come every year. -func normalizeDaysOfYear(days []string) ([]string, error) { - seen := map[string]bool{} - var out []string - for _, d := range days { - d = strings.TrimSpace(d) - if d == "" { - continue - } - parsed, err := time.Parse("01-02", d) - if err != nil || parsed.Format("01-02") != d { - return nil, fmt.Errorf("has %q, which is not a day of the year written as MM-DD", d) - } - if d == "02-29" { - return nil, errors.New("cannot use 02-29, which is not in every year") - } - if !seen[d] { - seen[d] = true - out = append(out, d) - } - } - if len(out) == 0 { - return nil, errors.New("must list at least one day of the year, as MM-DD") - } - sort.Strings(out) - return out, nil -} - -// fixedDateOnOrAfter is the first of the days of the year that falls on or after day. -func fixedDateOnOrAfter(days []string, day time.Time) time.Time { - days, err := normalizeDaysOfYear(days) - if err != nil { - days = []string{constants.LoyaltyExpiryFixedDatesDefault} - } - for year := day.Year(); ; year++ { - for _, d := range days { - parsed, _ := time.Parse("01-02", d) - candidate := time.Date(year, parsed.Month(), parsed.Day(), 0, 0, 0, 0, walletDisplayLocation) - if !candidate.Before(day) { - return candidate - } - } - } -} - -// walletDay is the calendar day of t in the customer's time zone, at midnight. -func walletDay(t time.Time) time.Time { - local := t.In(walletDisplayLocation) - return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, walletDisplayLocation) -} - -func endOfWalletDay(day time.Time) *time.Time { - end := time.Date(day.Year(), day.Month(), day.Day(), 23, 59, 59, 0, walletDisplayLocation) - return &end -} - -// addMonthsClamped adds months, keeping to the last day of a shorter month: 31 Jan -// plus one month is 28 or 29 Feb, not 3 Mar. -func addMonthsClamped(day time.Time, months int) time.Time { - first := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, day.Location()).AddDate(0, months, 0) - last := lastDayOfMonth(first).Day() - d := day.Day() - if d > last { - d = last - } - return time.Date(first.Year(), first.Month(), d, 0, 0, 0, 0, day.Location()) -} - -func lastDayOfMonth(day time.Time) time.Time { - return time.Date(day.Year(), day.Month()+1, 0, 0, 0, 0, 0, day.Location()) -} diff --git a/internal/processor/loyalty_expiry_test.go b/internal/processor/loyalty_expiry_test.go deleted file mode 100644 index c112f30..0000000 --- a/internal/processor/loyalty_expiry_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package processor - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/models" -) - -// wib is a moment in the customer's time zone. -func wib(year int, month time.Month, day, hour, minute int) time.Time { - return time.Date(year, month, day, hour, minute, 0, 0, walletDisplayLocation) -} - -// expiryDay is the day a lot expires on, as the apps show it. -func expiryDay(t *testing.T, at *time.Time) string { - t.Helper() - require.NotNil(t, at) - local := at.In(walletDisplayLocation) - assert.Equal(t, "23:59:59", local.Format("15:04:05"), "a lot lasts to the end of its day") - return local.Format("2006-01-02") -} - -func fixedDate(dates ...string) models.LoyaltyExpirySettings { - return models.LoyaltyExpirySettings{Enabled: true, Mode: "FIXED_DATE", FixedDates: dates, GraceMonths: 3} -} - -func rolling(period int64, unit string, endOfMonth bool) models.LoyaltyExpirySettings { - return models.LoyaltyExpirySettings{Enabled: true, Mode: "ROLLING", Period: period, Unit: unit, EndOfMonth: endOfMonth} -} - -func TestComputeExpiry_OffNeverExpires(t *testing.T) { - s := fixedDate("12-31") - s.Enabled = false - assert.Nil(t, ComputeExpiry(s, wib(2026, 5, 1, 10, 0))) -} - -func TestComputeExpiry_FixedDateWithGrace(t *testing.T) { - yearly := fixedDate("12-31") - for received, want := range map[time.Time]string{ - wib(2026, 1, 15, 9, 0): "2026-12-31", - wib(2026, 9, 30, 23, 0): "2026-12-31", // 30 Dec + nothing: still this year - wib(2026, 10, 1, 0, 30): "2027-12-31", // within 3 months of 31 Dec: next year - wib(2026, 12, 31, 8, 0): "2027-12-31", - } { - assert.Equal(t, want, expiryDay(t, ComputeExpiry(yearly, received)), received.String()) - } - - twice := fixedDate("12-31", "06-30") - assert.Equal(t, "2026-06-30", expiryDay(t, ComputeExpiry(twice, wib(2026, 3, 1, 12, 0)))) - assert.Equal(t, "2026-12-31", expiryDay(t, ComputeExpiry(twice, wib(2026, 4, 1, 12, 0)))) - assert.Equal(t, "2027-06-30", expiryDay(t, ComputeExpiry(twice, wib(2026, 10, 1, 12, 0)))) - - noGrace := fixedDate("12-31") - noGrace.GraceMonths = 0 - assert.Equal(t, "2026-12-31", expiryDay(t, ComputeExpiry(noGrace, wib(2026, 12, 31, 22, 0)))) -} - -// The day received is the customer's day: 20:00 UTC on 31 Mar is already 1 Apr in WIB. -func TestComputeExpiry_UsesTheCustomersDay(t *testing.T) { - received := time.Date(2026, 3, 31, 20, 0, 0, 0, time.UTC) - assert.Equal(t, "2026-04-11", expiryDay(t, ComputeExpiry(rolling(10, "DAY", false), received))) -} - -func TestComputeExpiry_Rolling(t *testing.T) { - assert.Equal(t, "2027-03-14", expiryDay(t, ComputeExpiry(rolling(12, "MONTH", false), wib(2026, 3, 14, 10, 0)))) - assert.Equal(t, "2027-03-31", expiryDay(t, ComputeExpiry(rolling(12, "MONTH", true), wib(2026, 3, 14, 10, 0)))) - assert.Equal(t, "2026-03-24", expiryDay(t, ComputeExpiry(rolling(10, "DAY", false), wib(2026, 3, 14, 10, 0)))) - // A month that is shorter keeps to its last day. - assert.Equal(t, "2026-02-28", expiryDay(t, ComputeExpiry(rolling(1, "MONTH", false), wib(2026, 1, 31, 10, 0)))) -} - -func TestActivationExpiry(t *testing.T) { - // FIXED_DATE: the second fixed date after activation, so nobody loses a balance - // weeks after the rule is announced. - assert.Equal(t, "2027-12-31", expiryDay(t, ActivationExpiry(fixedDate("12-31"), wib(2026, 11, 20, 10, 0)))) - assert.Equal(t, "2027-12-31", expiryDay(t, ActivationExpiry(fixedDate("12-31"), wib(2026, 12, 31, 10, 0)))) - assert.Equal(t, "2027-06-30", expiryDay(t, ActivationExpiry(fixedDate("06-30", "12-31"), wib(2026, 11, 20, 10, 0)))) - // ROLLING: a full period from activation. - assert.Equal(t, "2027-11-20", expiryDay(t, ActivationExpiry(rolling(12, "MONTH", false), wib(2026, 11, 20, 10, 0)))) -} - -func TestRefundExpiry(t *testing.T) { - now := wib(2026, 6, 1, 10, 0) - later := wib(2026, 12, 31, 23, 59) - soon := wib(2026, 6, 3, 23, 59) - past := wib(2026, 5, 1, 23, 59) - - assert.Nil(t, RefundExpiry(nil, now)) - assert.Equal(t, &later, RefundExpiry(&later, now)) - assert.Equal(t, "2026-06-08", expiryDay(t, RefundExpiry(&soon, now))) - assert.Equal(t, "2026-06-08", expiryDay(t, RefundExpiry(&past, now))) -} - -func TestEarlierExpiry(t *testing.T) { - a, b := wib(2026, 1, 1, 0, 0), wib(2026, 2, 1, 0, 0) - assert.Equal(t, &a, EarlierExpiry(&a, &b)) - assert.Equal(t, &a, EarlierExpiry(&b, &a)) - assert.Equal(t, &a, EarlierExpiry(nil, &a)) - assert.Equal(t, &a, EarlierExpiry(&a, nil)) - assert.Nil(t, EarlierExpiry(nil, nil)) -} - -func TestNormalizeDaysOfYear(t *testing.T) { - days, err := normalizeDaysOfYear([]string{" 12-31", "06-30", "12-31", ""}) - require.NoError(t, err) - assert.Equal(t, []string{"06-30", "12-31"}, days) - for _, bad := range [][]string{{}, {"13-01"}, {"12-32"}, {"1-31"}, {"02-29"}, {"31-12"}} { - _, err := normalizeDaysOfYear(bad) - assert.Error(t, err, bad) - } -} diff --git a/internal/processor/loyalty_settings_processor.go b/internal/processor/loyalty_settings_processor.go deleted file mode 100644 index 2b78e45..0000000 --- a/internal/processor/loyalty_settings_processor.go +++ /dev/null @@ -1,530 +0,0 @@ -package processor - -import ( - "context" - "errors" - "fmt" - "strconv" - "strings" - "time" - - "github.com/google/uuid" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// ErrInvalidLoyaltySettings wraps every rejection of a setting that breaks the rules in -// docs/prd-point-coin.md F1, F2 and F12. -var ErrInvalidLoyaltySettings = errors.New("invalid loyalty settings") - -// LoyaltySettingsProcessor reads and writes the loyalty settings of outlets (F1) and -// organizations (F2, F12). Reads always return every setting, with the PRD default for -// a key that was never set, so callers never handle a missing value. Every write -// records what changed in loyalty_setting_changes. -type LoyaltySettingsProcessor struct { - repo repository.LoyaltySettingsRepository - tx TxRunner - now func() time.Time -} - -func NewLoyaltySettingsProcessor(repo repository.LoyaltySettingsRepository, tx TxRunner) *LoyaltySettingsProcessor { - return &LoyaltySettingsProcessor{repo: repo, tx: tx, now: time.Now} -} - -// Outlet returns an outlet's settings. -func (p *LoyaltySettingsProcessor) Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) { - raw, err := p.repo.GetOutletValues(ctx, outletID) - if err != nil { - return nil, err - } - var s models.OutletLoyaltySettings - loadLoyaltyFields(outletLoyaltyFields(&s), raw, "outlet "+outletID.String()) - return &s, nil -} - -// OutletForOrganization returns an outlet's settings for the dashboard, and -// repository.ErrLoyaltyOutletNotFound unless the outlet belongs to the organization. -func (p *LoyaltySettingsProcessor) OutletForOrganization(ctx context.Context, organizationID, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) { - owner, err := p.repo.OutletOrganizationID(ctx, outletID) - if err != nil { - return nil, err - } - if owner != organizationID { - return nil, repository.ErrLoyaltyOutletNotFound - } - return p.Outlet(ctx, outletID) -} - -// Organization returns an organization's settings. -func (p *LoyaltySettingsProcessor) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) { - raw, err := p.repo.GetOrganizationValues(ctx, organizationID) - if err != nil { - return nil, err - } - var s models.OrganizationLoyaltySettings - loadLoyaltyFields(organizationLoyaltyFields(&s), raw, "organization "+organizationID.String()) - return &s, nil -} - -// PointValue is the rupiah value of one EnakPoint in an organization. -func (p *LoyaltySettingsProcessor) PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) { - s, err := p.Organization(ctx, organizationID) - if err != nil { - return 0, err - } - return s.PointValue, nil -} - -// UpdateOutlet replaces an outlet's settings and returns what changed. The outlet must -// belong to the organization; otherwise repository.ErrLoyaltyOutletNotFound. -func (p *LoyaltySettingsProcessor) UpdateOutlet(ctx context.Context, organizationID, outletID, changedBy uuid.UUID, settings models.OutletLoyaltySettings) ([]models.LoyaltySettingChange, error) { - if err := validateLoyaltyFields(outletLoyaltyFields(&settings)); err != nil { - return nil, err - } - var changes []models.LoyaltySettingChange - err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { - owner, err := p.repo.OutletOrganizationID(ctx, outletID) - if err != nil { - return err - } - if owner != organizationID { - return repository.ErrLoyaltyOutletNotFound - } - if err := p.repo.LockScope(ctx, outletID); err != nil { - return err - } - raw, err := p.repo.GetOutletValues(ctx, outletID) - if err != nil { - return err - } - var current models.OutletLoyaltySettings - loadLoyaltyFields(outletLoyaltyFields(¤t), raw, "outlet "+outletID.String()) - - changes, err = p.apply(ctx, organizationID, &outletID, changedBy, raw, - outletLoyaltyFields(¤t), outletLoyaltyFields(&settings), - func(key string, value *string) error { return p.repo.SetOutletValue(ctx, outletID, key, value) }) - return err - }) - return changes, err -} - -// UpdateOrganization replaces an organization's settings and returns what changed. -// -// Turning expiry on for a currency for the first time also gives every existing lot -// without an expiry the activation expiry (F12, note N4), and the result lists them. -func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organizationID, changedBy uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, []models.LoyaltyExpiryActivation, error) { - if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil { - return nil, nil, err - } - var changes []models.LoyaltySettingChange - var activations []models.LoyaltyExpiryActivation - err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { - if err := p.repo.LockScope(ctx, organizationID); err != nil { - return err - } - raw, err := p.repo.GetOrganizationValues(ctx, organizationID) - if err != nil { - return err - } - var current models.OrganizationLoyaltySettings - loadLoyaltyFields(organizationLoyaltyFields(¤t), raw, "organization "+organizationID.String()) - - changes, err = p.apply(ctx, organizationID, nil, changedBy, raw, - organizationLoyaltyFields(¤t), organizationLoyaltyFields(&settings), - func(key string, value *string) error { - return p.repo.SetOrganizationValue(ctx, organizationID, key, value) - }) - if err != nil { - return err - } - activations, err = p.expiryActivations(ctx, organizationID, current, settings, true) - return err - }) - if err != nil { - return nil, nil, err - } - return changes, activations, nil -} - -// expiryActivations lists the currencies whose expiry these settings turn on, with the -// balances that have no expiry yet and the expiry they get: a full period from now -// (note N4, decided). With apply, it dates those lots. -func (p *LoyaltySettingsProcessor) expiryActivations(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, apply bool) ([]models.LoyaltyExpiryActivation, error) { - activations := []models.LoyaltyExpiryActivation{} - for _, currency := range []string{constants.WalletCurrencyPoint, constants.WalletCurrencyCoin} { - was, will := ExpirySettings(&before, currency), ExpirySettings(&after, currency) - if was.Enabled || !will.Enabled { - continue - } - expiresAt := *ActivationExpiry(will, p.now()) - var lots, amount int64 - var err error - if apply { - lots, amount, err = p.repo.DateUndatedLots(ctx, organizationID, currency, expiresAt) - } else { - lots, amount, err = p.repo.UndatedBalance(ctx, organizationID, currency) - } - if err != nil { - return nil, err - } - activations = append(activations, models.LoyaltyExpiryActivation{Currency: currency, Lots: lots, Amount: amount, ExpiresAt: expiresAt}) - } - return activations, nil -} - -// apply stores every field whose effective value changes and records it. A key left -// on its default and not changed is not written, so the history only shows real -// changes. current and next list the same keys in the same order. -func (p *LoyaltySettingsProcessor) apply(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, changedBy uuid.UUID, raw map[string]string, current, next []loyaltyField, store func(key string, value *string) error) ([]models.LoyaltySettingChange, error) { - rows := diffLoyaltyFields(organizationID, outletID, changedBy, raw, current, next) - for _, row := range rows { - if err := store(row.Key, row.NewValue); err != nil { - return nil, err - } - } - if err := p.repo.InsertChanges(ctx, rows); err != nil { - return nil, err - } - changes := make([]models.LoyaltySettingChange, 0, len(rows)) - for _, row := range rows { - changes = append(changes, loyaltySettingChangeModel(row)) - } - return changes, nil -} - -// ListChanges returns a page of an organization's settings history, newest first. -// A nil outlet lists the whole organization, outlets included. -func (p *LoyaltySettingsProcessor) ListChanges(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int) (*models.PaginatedResponse[models.LoyaltySettingChange], error) { - if page < 1 { - page = 1 - } - if limit < 1 || limit > 100 { - limit = 20 - } - rows, total, err := p.repo.ListChanges(ctx, repository.LoyaltySettingChangeFilter{ - OrganizationID: organizationID, OutletID: outletID, Offset: (page - 1) * limit, Limit: limit, - }) - if err != nil { - return nil, err - } - items := make([]models.LoyaltySettingChange, 0, len(rows)) - for _, row := range rows { - items = append(items, loyaltySettingChangeModel(row)) - } - return &models.PaginatedResponse[models.LoyaltySettingChange]{ - Data: items, - Pagination: models.Pagination{ - Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit)), - }, - }, nil -} - -func loyaltySettingChangeModel(row entities.LoyaltySettingChange) models.LoyaltySettingChange { - return models.LoyaltySettingChange{ - ID: row.ID, - OrganizationID: row.OrganizationID, - OutletID: row.OutletID, - Key: row.Key, - OldValue: row.OldValue, - NewValue: row.NewValue, - ChangedBy: row.ChangedBy, - CreatedAt: row.CreatedAt, - } -} - -// outletLoyaltyFields describes every outlet key (F1), bound to s. -func outletLoyaltyFields(s *models.OutletLoyaltySettings) []loyaltyField { - return []loyaltyField{ - boolLoyaltyField(constants.LoyaltyPointEnabledKey, &s.Point.Enabled, false), - intLoyaltyField(constants.LoyaltyPointEarnPerAmountKey, &s.Point.EarnPerAmount, constants.LoyaltyPointEarnPerAmountDefault, 1, noLoyaltyMax), - intLoyaltyField(constants.LoyaltyPointEarnValueKey, &s.Point.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax), - intLoyaltyField(constants.LoyaltyPointMinOrderAmountKey, &s.Point.MinOrderAmount, 0, 0, noLoyaltyMax), - optionalIntLoyaltyField(constants.LoyaltyPointMaxPerOrderKey, &s.Point.MaxPerOrder, 0), - - boolLoyaltyField(constants.LoyaltyCoinEnabledKey, &s.Coin.Enabled, false), - intLoyaltyField(constants.LoyaltyCoinEarnPerAmountKey, &s.Coin.EarnPerAmount, constants.LoyaltyCoinEarnPerAmountDefault, 1, noLoyaltyMax), - intLoyaltyField(constants.LoyaltyCoinEarnValueKey, &s.Coin.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax), - intLoyaltyField(constants.LoyaltyCoinMinOrderAmountKey, &s.Coin.MinOrderAmount, 0, 0, noLoyaltyMax), - optionalIntLoyaltyField(constants.LoyaltyCoinMaxPerOrderKey, &s.Coin.MaxPerOrder, 0), - - boolLoyaltyField(constants.LoyaltyPointAcceptPaymentKey, &s.PointPayment.AcceptPayment, false), - intLoyaltyField(constants.LoyaltyPointMinPaymentPointsKey, &s.PointPayment.MinPaymentPoints, constants.LoyaltyMinPaymentPointsDefault, 1, noLoyaltyMax), - intLoyaltyField(constants.LoyaltyPointMaxPaymentPercentKey, &s.PointPayment.MaxPaymentPercent, constants.LoyaltyMaxPaymentPercentDefault, 0, 100), - } -} - -// organizationLoyaltyFields describes every organization key (F2, F12), bound to s. -func organizationLoyaltyFields(s *models.OrganizationLoyaltySettings) []loyaltyField { - fields := []loyaltyField{ - intLoyaltyField(constants.LoyaltyPointValueKey, &s.PointValue, constants.LoyaltyPointValueDefault, 1, noLoyaltyMax), - intLoyaltyField(constants.LoyaltyExchangeCoinAmountKey, &s.Exchange.CoinAmount, constants.LoyaltyExchangeAmountDefault, 1, noLoyaltyMax), - intLoyaltyField(constants.LoyaltyExchangePointAmountKey, &s.Exchange.PointAmount, constants.LoyaltyExchangeAmountDefault, 1, noLoyaltyMax), - boolLoyaltyField(constants.LoyaltyTransferEnabledKey, &s.Transfer.Enabled, constants.LoyaltyTransferEnabledDefault), - intLoyaltyField(constants.LoyaltyTransferMinAmountKey, &s.Transfer.MinAmount, constants.LoyaltyTransferMinAmountDefault, 1, noLoyaltyMax), - optionalIntLoyaltyField(constants.LoyaltyTransferMaxPerTransactionKey, &s.Transfer.MaxPerTransaction, 1), - optionalIntLoyaltyField(constants.LoyaltyTransferDailyLimitKey, &s.Transfer.DailyLimit, 1), - } - fields = append(fields, expiryLoyaltyFields("loyalty.point.", &s.PointExpiry)...) - return append(fields, expiryLoyaltyFields("loyalty.coin.", &s.CoinExpiry)...) -} - -func expiryLoyaltyFields(prefix string, s *models.LoyaltyExpirySettings) []loyaltyField { - return []loyaltyField{ - boolLoyaltyField(prefix+constants.LoyaltyExpiryEnabledSuffix, &s.Enabled, false), - enumLoyaltyField(prefix+constants.LoyaltyExpiryModeSuffix, &s.Mode, constants.LoyaltyExpiryModeDefault, - constants.LoyaltyExpiryModeFixedDate, constants.LoyaltyExpiryModeRolling), - dayOfYearListLoyaltyField(prefix+constants.LoyaltyExpiryFixedDatesSuffix, &s.FixedDates, constants.LoyaltyExpiryFixedDatesDefault), - intLoyaltyField(prefix+constants.LoyaltyExpiryGraceMonthsSuffix, &s.GraceMonths, constants.LoyaltyExpiryGraceMonthsDefault, 0, loyaltyExpiryMaxGraceMonths), - intLoyaltyField(prefix+constants.LoyaltyExpiryPeriodSuffix, &s.Period, constants.LoyaltyExpiryPeriodDefault, 1, noLoyaltyMax), - enumLoyaltyField(prefix+constants.LoyaltyExpiryUnitSuffix, &s.Unit, constants.LoyaltyExpiryUnitDefault, - constants.LoyaltyExpiryUnitDay, constants.LoyaltyExpiryUnitMonth), - boolLoyaltyField(prefix+constants.LoyaltyExpiryEndOfMonthSuffix, &s.EndOfMonth, false), - intLoyaltyField(prefix+constants.LoyaltyExpiryReminderDaysSuffix, &s.ReminderDays, constants.LoyaltyExpiryReminderDaysDefault, 0, noLoyaltyMax), - } -} - -// loyaltyField is one setting key bound to the struct field that holds it. The same -// description drives reading (parse or default), validating, and writing (format). -type loyaltyField struct { - key string - // parse sets the field from a stored value, or reports it unusable. - parse func(raw string) bool - // reset sets the field to its default. - reset func() - // validate checks the field's current value. - validate func() error - // format is the field's current value as stored; nil means no stored value. - format func() *string -} - -const noLoyaltyMax = int64(-1) - -func intLoyaltyField(key string, v *int64, def, min, max int64) loyaltyField { - check := func(n int64) error { - if n < min { - return fmt.Errorf("%w: %s must be at least %d", ErrInvalidLoyaltySettings, key, min) - } - if max != noLoyaltyMax && n > max { - return fmt.Errorf("%w: %s must be at most %d", ErrInvalidLoyaltySettings, key, max) - } - return nil - } - return loyaltyField{ - key: key, - parse: func(raw string) bool { - n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) - if err != nil || check(n) != nil { - return false - } - *v = n - return true - }, - reset: func() { *v = def }, - validate: func() error { return check(*v) }, - format: func() *string { s := strconv.FormatInt(*v, 10); return &s }, - } -} - -// optionalIntLoyaltyField is a limit that may be unset, meaning no limit. -func optionalIntLoyaltyField(key string, v **int64, min int64) loyaltyField { - return loyaltyField{ - key: key, - parse: func(raw string) bool { - n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) - if err != nil || n < min { - return false - } - *v = &n - return true - }, - reset: func() { *v = nil }, - validate: func() error { - if *v != nil && **v < min { - return fmt.Errorf("%w: %s must be at least %d, or empty for no limit", ErrInvalidLoyaltySettings, key, min) - } - return nil - }, - format: func() *string { - if *v == nil { - return nil - } - s := strconv.FormatInt(**v, 10) - return &s - }, - } -} - -func boolLoyaltyField(key string, v *bool, def bool) loyaltyField { - return loyaltyField{ - key: key, - parse: func(raw string) bool { - b, err := strconv.ParseBool(strings.TrimSpace(raw)) - if err != nil { - return false - } - *v = b - return true - }, - reset: func() { *v = def }, - validate: func() error { return nil }, - format: func() *string { s := strconv.FormatBool(*v); return &s }, - } -} - -func enumLoyaltyField(key string, v *string, def string, allowed ...string) loyaltyField { - valid := func(s string) bool { - for _, a := range allowed { - if s == a { - return true - } - } - return false - } - return loyaltyField{ - key: key, - parse: func(raw string) bool { - s := strings.ToUpper(strings.TrimSpace(raw)) - if !valid(s) { - return false - } - *v = s - return true - }, - reset: func() { *v = def }, - validate: func() error { - if !valid(*v) { - return fmt.Errorf("%w: %s must be one of %s", ErrInvalidLoyaltySettings, key, strings.Join(allowed, ", ")) - } - return nil - }, - format: func() *string { s := *v; return &s }, - } -} - -// dayOfYearListLoyaltyField is a non-empty set of days of the year, stored as sorted -// MM-DD values joined by commas, such as "06-30,12-31". -func dayOfYearListLoyaltyField(key string, v *[]string, def string) loyaltyField { - normalize := func(days []string) ([]string, error) { - out, err := normalizeDaysOfYear(days) - if err != nil { - return nil, fmt.Errorf("%w: %s %v", ErrInvalidLoyaltySettings, key, err) - } - return out, nil - } - return loyaltyField{ - key: key, - parse: func(raw string) bool { - days, err := normalize(strings.Split(raw, ",")) - if err != nil { - return false - } - *v = days - return true - }, - reset: func() { *v, _ = normalizeDaysOfYear(strings.Split(def, ",")) }, - validate: func() error { - days, err := normalize(*v) - if err != nil { - return err - } - *v = days - return nil - }, - format: func() *string { - days, err := normalizeDaysOfYear(*v) - if err != nil { - days = *v - } - s := strings.Join(days, ",") - return &s - }, - } -} - -// loadLoyaltyFields fills each field from its stored value, or its default when there -// is none. A stored value that cannot be used also falls back to the default, so a bad -// row can never reach a calculation, and it is logged so it gets fixed. -func loadLoyaltyFields(fields []loyaltyField, raw map[string]string, scope string) { - for _, f := range fields { - f.reset() - value, ok := raw[f.key] - if !ok { - continue - } - if !f.parse(value) { - f.reset() - logger.NonContext.Warnf("Loyalty setting %s of %s has an unusable value %q; using the default", f.key, scope, value) - } - } -} - -func validateLoyaltyFields(fields []loyaltyField) error { - for _, f := range fields { - if err := f.validate(); err != nil { - return err - } - } - return nil -} - -func equalOptionalStrings(a, b *string) bool { - if a == nil || b == nil { - return a == nil && b == nil - } - return *a == *b -} - -// diffLoyaltyFields lists the keys whose effective value differs between current and -// next, with the stored old value (nil when the key was on its default). current and -// next list the same keys in the same order. -func diffLoyaltyFields(organizationID uuid.UUID, outletID *uuid.UUID, changedBy uuid.UUID, raw map[string]string, current, next []loyaltyField) []entities.LoyaltySettingChange { - var rows []entities.LoyaltySettingChange - for i, field := range next { - newValue := field.format() - if equalOptionalStrings(current[i].format(), newValue) { - continue - } - var oldValue *string - if v, ok := raw[field.key]; ok { - oldValue = &v - } - rows = append(rows, entities.LoyaltySettingChange{ - OrganizationID: organizationID, - OutletID: outletID, - Key: field.key, - OldValue: oldValue, - NewValue: newValue, - ChangedBy: changedBy, - }) - } - return rows -} - -// PreviewOrganization validates new organization settings and lists what saving them -// would change, and which balances turning expiry on would date, without writing -// anything. -func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, organizationID uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, []models.LoyaltyExpiryActivation, error) { - if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil { - return nil, nil, err - } - raw, err := p.repo.GetOrganizationValues(ctx, organizationID) - if err != nil { - return nil, nil, err - } - var current models.OrganizationLoyaltySettings - loadLoyaltyFields(organizationLoyaltyFields(¤t), raw, "organization "+organizationID.String()) - rows := diffLoyaltyFields(organizationID, nil, uuid.Nil, raw, organizationLoyaltyFields(¤t), organizationLoyaltyFields(&settings)) - changes := make([]models.LoyaltySettingChange, 0, len(rows)) - for _, row := range rows { - changes = append(changes, loyaltySettingChangeModel(row)) - } - activations, err := p.expiryActivations(ctx, organizationID, current, settings, false) - if err != nil { - return nil, nil, err - } - return changes, activations, nil -} diff --git a/internal/processor/loyalty_settings_processor_test.go b/internal/processor/loyalty_settings_processor_test.go deleted file mode 100644 index ecbdb1e..0000000 --- a/internal/processor/loyalty_settings_processor_test.go +++ /dev/null @@ -1,385 +0,0 @@ -package processor - -import ( - "context" - "os" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -func TestMain(m *testing.M) { - // Some processors log; the server sets the logger up at startup. - logger.Setup("fatal", "json") - os.Exit(m.Run()) -} - -// loyaltyRepoFake keeps settings in maps. It does not check for a transaction. -type loyaltyRepoFake struct { - outletOrg map[uuid.UUID]uuid.UUID - outletValues map[string]string - orgValues map[string]string - changes []entities.LoyaltySettingChange - locks []uuid.UUID - // Lots without an expiry per currency: how many and what they hold. - undated map[string][2]int64 - dated map[string]time.Time -} - -func (f *loyaltyRepoFake) GetOutletValues(context.Context, uuid.UUID) (map[string]string, error) { - return copyStringMap(f.outletValues), nil -} - -func (f *loyaltyRepoFake) GetOrganizationValues(context.Context, uuid.UUID) (map[string]string, error) { - return copyStringMap(f.orgValues), nil -} - -func (f *loyaltyRepoFake) OutletOrganizationID(_ context.Context, outletID uuid.UUID) (uuid.UUID, error) { - org, ok := f.outletOrg[outletID] - if !ok { - return uuid.Nil, repository.ErrLoyaltyOutletNotFound - } - return org, nil -} - -func (f *loyaltyRepoFake) LockScope(_ context.Context, id uuid.UUID) error { - f.locks = append(f.locks, id) - return nil -} - -func (f *loyaltyRepoFake) SetOutletValue(_ context.Context, _ uuid.UUID, key string, value *string) error { - f.outletValues = setOptional(f.outletValues, key, value) - return nil -} - -func (f *loyaltyRepoFake) SetOrganizationValue(_ context.Context, _ uuid.UUID, key string, value *string) error { - f.orgValues = setOptional(f.orgValues, key, value) - return nil -} - -func (f *loyaltyRepoFake) InsertChanges(_ context.Context, changes []entities.LoyaltySettingChange) error { - f.changes = append(f.changes, changes...) - return nil -} - -func (f *loyaltyRepoFake) ListChanges(context.Context, repository.LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) { - return f.changes, int64(len(f.changes)), nil -} - -func copyStringMap(m map[string]string) map[string]string { - out := make(map[string]string, len(m)) - for k, v := range m { - out[k] = v - } - return out -} - -func setOptional(m map[string]string, key string, value *string) map[string]string { - if m == nil { - m = map[string]string{} - } - if value == nil { - delete(m, key) - } else { - m[key] = *value - } - return m -} - -// txRunnerFake runs fn in place. -type txRunnerFake struct{} - -func (txRunnerFake) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error { - return fn(ctx) -} - -func TestLoyaltySettings_OutletWithoutSettingsGetsEveryDefault(t *testing.T) { - p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{}) - s, err := p.Outlet(context.Background(), uuid.New()) - require.NoError(t, err) - assert.Equal(t, models.OutletLoyaltySettings{ - Point: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 100, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil}, - Coin: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 25000, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil}, - PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100}, - }, *s) -} - -func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing.T) { - p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{}) - s, err := p.Organization(context.Background(), uuid.New()) - require.NoError(t, err) - expiry := models.LoyaltyExpirySettings{Enabled: false, Mode: "FIXED_DATE", FixedDates: []string{"12-31"}, GraceMonths: 3, Period: 12, Unit: "MONTH", EndOfMonth: false, ReminderDays: 7} - assert.Equal(t, models.OrganizationLoyaltySettings{ - PointValue: 1, - Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}, - Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1}, - PointExpiry: expiry, - CoinExpiry: expiry, - }, *s) -} - -func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) { - repo := &loyaltyRepoFake{ - outletValues: map[string]string{ - constants.LoyaltyPointEnabledKey: "true", - constants.LoyaltyPointEarnPerAmountKey: " 1000 ", - constants.LoyaltyPointMaxPerOrderKey: "500", - constants.LoyaltyPointMaxPaymentPercentKey: "50", - "loyalty.unknown": "ignored", - }, - orgValues: map[string]string{ - constants.LoyaltyPointValueKey: "100", - constants.LoyaltyTransferDailyLimitKey: "10000", - "loyalty.coin.expiry_unit": "day", - "loyalty.point.expiry_fixed_dates": "12-31, 06-30,12-31", - "loyalty.coin.expiry_mode": "rolling", - }, - } - p := NewLoyaltySettingsProcessor(repo, txRunnerFake{}) - - outlet, err := p.Outlet(context.Background(), uuid.New()) - require.NoError(t, err) - assert.True(t, outlet.Point.Enabled) - assert.Equal(t, int64(1000), outlet.Point.EarnPerAmount) - assert.Equal(t, int64(500), *outlet.Point.MaxPerOrder) - assert.Equal(t, int64(50), outlet.PointPayment.MaxPaymentPercent) - - org, err := p.Organization(context.Background(), uuid.New()) - require.NoError(t, err) - assert.Equal(t, int64(100), org.PointValue) - assert.Equal(t, int64(10000), *org.Transfer.DailyLimit) - assert.Equal(t, "DAY", org.CoinExpiry.Unit) - assert.Equal(t, "MONTH", org.PointExpiry.Unit) - assert.Equal(t, []string{"06-30", "12-31"}, org.PointExpiry.FixedDates) - assert.Equal(t, "ROLLING", org.CoinExpiry.Mode) - assert.Equal(t, "FIXED_DATE", org.PointExpiry.Mode) -} - -// A bad row must never reach a calculation: earn_per_amount 0 would divide by zero. -func TestLoyaltySettings_UnusableStoredValuesFallBackToDefault(t *testing.T) { - repo := &loyaltyRepoFake{ - outletValues: map[string]string{ - constants.LoyaltyPointEnabledKey: "yes please", - constants.LoyaltyPointEarnPerAmountKey: "0", - constants.LoyaltyCoinEarnValueKey: "-1", - constants.LoyaltyPointMaxPerOrderKey: "abc", - constants.LoyaltyPointMaxPaymentPercentKey: "150", - }, - orgValues: map[string]string{ - constants.LoyaltyPointValueKey: "0", - "loyalty.point.expiry_unit": "YEAR", - "loyalty.point.expiry_period": "", - }, - } - p := NewLoyaltySettingsProcessor(repo, txRunnerFake{}) - - outlet, err := p.Outlet(context.Background(), uuid.New()) - require.NoError(t, err) - assert.False(t, outlet.Point.Enabled) - assert.Equal(t, int64(100), outlet.Point.EarnPerAmount) - assert.Equal(t, int64(1), outlet.Coin.EarnValue) - assert.Nil(t, outlet.Point.MaxPerOrder) - assert.Equal(t, int64(100), outlet.PointPayment.MaxPaymentPercent) - - for name, raw := range map[string]string{"not set": "", "garbage": "abc", "zero": "0", "negative": "-5"} { - repo.orgValues = map[string]string{} - if raw != "" { - repo.orgValues[constants.LoyaltyPointValueKey] = raw - } - value, err := p.PointValue(context.Background(), uuid.New()) - require.NoError(t, err) - assert.Equal(t, constants.LoyaltyPointValueDefault, value, name) - } -} - -func TestLoyaltySettings_UpdateRecordsOnlyRealChanges(t *testing.T) { - org, outlet, admin := uuid.New(), uuid.New(), uuid.New() - repo := &loyaltyRepoFake{ - outletOrg: map[uuid.UUID]uuid.UUID{outlet: org}, - outletValues: map[string]string{constants.LoyaltyPointEarnPerAmountKey: "200"}, - } - p := NewLoyaltySettingsProcessor(repo, txRunnerFake{}) - ctx := context.Background() - - settings, err := p.Outlet(ctx, outlet) - require.NoError(t, err) - settings.Point.Enabled = true // default false -> true - settings.Point.EarnPerAmount = 100 // stored 200 -> 100 (the default, but set explicitly) - settings.Point.MaxPerOrder = ptr(int64(0)) // unset -> 0 - // Everything else stays on its default. - - changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings) - require.NoError(t, err) - byKey := map[string]models.LoyaltySettingChange{} - for _, c := range changes { - byKey[c.Key] = c - assert.Equal(t, admin, c.ChangedBy) - assert.Equal(t, org, c.OrganizationID) - assert.Equal(t, &outlet, c.OutletID) - } - require.Len(t, byKey, 3, "only the three keys that changed") - assert.Nil(t, byKey[constants.LoyaltyPointEnabledKey].OldValue, "was on its default") - assert.Equal(t, "true", *byKey[constants.LoyaltyPointEnabledKey].NewValue) - assert.Equal(t, "200", *byKey[constants.LoyaltyPointEarnPerAmountKey].OldValue) - assert.Equal(t, "100", *byKey[constants.LoyaltyPointEarnPerAmountKey].NewValue) - assert.Equal(t, "0", *byKey[constants.LoyaltyPointMaxPerOrderKey].NewValue) - assert.Len(t, repo.changes, 3) - assert.Equal(t, []uuid.UUID{outlet}, repo.locks) - - after, err := p.Outlet(ctx, outlet) - require.NoError(t, err) - assert.Equal(t, *settings, *after) - - // Saving the same settings again changes nothing and records nothing. - changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after) - require.NoError(t, err) - assert.Empty(t, changes) - assert.Len(t, repo.changes, 3) - - // Clearing a limit removes the stored value, recorded as a change to nothing. - after.Point.MaxPerOrder = nil - changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after) - require.NoError(t, err) - require.Len(t, changes, 1) - assert.Equal(t, "0", *changes[0].OldValue) - assert.Nil(t, changes[0].NewValue) - assert.NotContains(t, repo.outletValues, constants.LoyaltyPointMaxPerOrderKey) -} - -func TestLoyaltySettings_UpdateOrganization(t *testing.T) { - org, admin := uuid.New(), uuid.New() - repo := &loyaltyRepoFake{} - p := NewLoyaltySettingsProcessor(repo, txRunnerFake{}) - ctx := context.Background() - - settings, err := p.Organization(ctx, org) - require.NoError(t, err) - settings.PointValue = 100 - settings.CoinExpiry.Enabled = true - settings.CoinExpiry.Unit = constants.LoyaltyExpiryUnitDay - - changes, _, err := p.UpdateOrganization(ctx, org, admin, *settings) - require.NoError(t, err) - keys := []string{} - for _, c := range changes { - keys = append(keys, c.Key) - assert.Nil(t, c.OutletID, "organization settings have no outlet") - } - assert.ElementsMatch(t, []string{constants.LoyaltyPointValueKey, "loyalty.coin.expiry_enabled", "loyalty.coin.expiry_unit"}, keys) - - value, err := p.PointValue(ctx, org) - require.NoError(t, err) - assert.Equal(t, int64(100), value) -} - -func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) { - org, outlet := uuid.New(), uuid.New() - repo := &loyaltyRepoFake{outletOrg: map[uuid.UUID]uuid.UUID{outlet: org}} - p := NewLoyaltySettingsProcessor(repo, txRunnerFake{}) - ctx := context.Background() - - for name, mutate := range map[string]func(*models.OutletLoyaltySettings){ - "earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 }, - "negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 }, - "negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 }, - "negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) }, - "payment percent over 100": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = 101 }, - "negative payment percent": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = -1 }, - "min payment points 0": func(s *models.OutletLoyaltySettings) { s.PointPayment.MinPaymentPoints = 0 }, - } { - s, err := p.Outlet(ctx, outlet) - require.NoError(t, err) - mutate(s) - _, err = p.UpdateOutlet(ctx, org, outlet, uuid.New(), *s) - assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name) - } - - for name, mutate := range map[string]func(*models.OrganizationLoyaltySettings){ - "point value 0": func(s *models.OrganizationLoyaltySettings) { s.PointValue = 0 }, - "exchange coin amount 0": func(s *models.OrganizationLoyaltySettings) { s.Exchange.CoinAmount = 0 }, - "transfer min 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.MinAmount = 0 }, - "daily limit 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.DailyLimit = ptr(int64(0)) }, - "expiry period 0": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Period = 0 }, - "expiry unit YEAR": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.Unit = "YEAR" }, - "negative reminder days": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.ReminderDays = -1 }, - "expiry mode YEARLY": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Mode = "YEARLY" }, - "no fixed dates": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.FixedDates = nil }, - "fixed date 13-01": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.FixedDates = []string{"13-01"} }, - "fixed date 02-29": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.FixedDates = []string{"02-29"} }, - "negative grace": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.GraceMonths = -1 }, - } { - s, err := p.Organization(ctx, org) - require.NoError(t, err) - mutate(s) - _, _, err = p.UpdateOrganization(ctx, org, uuid.New(), *s) - assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name) - } - assert.Empty(t, repo.changes, "nothing is written when a value is invalid") - assert.Empty(t, repo.locks, "invalid input is rejected before touching the database") - - // An outlet of another organization. - s, err := p.Outlet(ctx, outlet) - require.NoError(t, err) - _, err = p.UpdateOutlet(ctx, uuid.New(), outlet, uuid.New(), *s) - assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound) - _, err = p.UpdateOutlet(ctx, org, uuid.New(), uuid.New(), *s) - assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound) -} - -func (f *loyaltyRepoFake) UndatedBalance(_ context.Context, _ uuid.UUID, currency string) (int64, int64, error) { - u := f.undated[currency] - return u[0], u[1], nil -} - -func (f *loyaltyRepoFake) DateUndatedLots(_ context.Context, _ uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) { - u := f.undated[currency] - if f.dated == nil { - f.dated = map[string]time.Time{} - } - f.dated[currency] = expiresAt - delete(f.undated, currency) - return u[0], u[1], nil -} - -// Turning expiry on dates the balances that had none, a full cycle away; a dry run -// only counts them, and saving again dates nothing more. -func TestLoyaltySettings_TurningExpiryOnDatesExistingBalances(t *testing.T) { - org, admin := uuid.New(), uuid.New() - repo := &loyaltyRepoFake{undated: map[string][2]int64{"POINT": {3, 1500}, "COIN": {2, 40}}} - p := NewLoyaltySettingsProcessor(repo, txRunnerFake{}) - p.now = func() time.Time { return time.Date(2026, 11, 20, 3, 0, 0, 0, time.UTC) } - ctx := context.Background() - - settings, err := p.Organization(ctx, org) - require.NoError(t, err) - settings.PointExpiry.Enabled = true - - _, preview, err := p.PreviewOrganization(ctx, org, *settings) - require.NoError(t, err) - require.Len(t, preview, 1) - assert.Equal(t, "POINT", preview[0].Currency) - assert.Equal(t, int64(1500), preview[0].Amount) - assert.Empty(t, repo.dated, "a dry run dates nothing") - - _, activations, err := p.UpdateOrganization(ctx, org, admin, *settings) - require.NoError(t, err) - require.Len(t, activations, 1) - assert.Equal(t, int64(3), activations[0].Lots) - // Default FIXED_DATE on 31 Dec: the second one after 20 Nov 2026. - assert.Equal(t, "2027-12-31", activations[0].ExpiresAt.In(walletDisplayLocation).Format("2006-01-02")) - assert.Equal(t, activations[0].ExpiresAt, repo.dated["POINT"]) - assert.NotContains(t, repo.dated, "COIN", "EnakCoin expiry stays off") - - _, again, err := p.UpdateOrganization(ctx, org, admin, *settings) - require.NoError(t, err) - assert.Empty(t, again, "already on: nothing is activated twice") -} diff --git a/internal/processor/order_paid_hook_test.go b/internal/processor/order_paid_hook_test.go deleted file mode 100644 index bed9ef9..0000000 --- a/internal/processor/order_paid_hook_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package processor - -import ( - "context" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// These fakes embed the interface they stand for and implement only what the paths -// under test call; anything else would panic, which would show a path doing more than -// expected. -type hookOrderRepo struct { - OrderRepository - order *entities.Order - statusUpdates int - hookCallsAtPay int // hook calls seen when the status was written - hook *orderPaidHookFake -} - -func (r *hookOrderRepo) GetByID(context.Context, uuid.UUID) (*entities.Order, error) { - o := *r.order - return &o, nil -} - -func (r *hookOrderRepo) GetWithRelations(context.Context, uuid.UUID) (*entities.Order, error) { - o := *r.order - return &o, nil -} - -func (r *hookOrderRepo) UpdateStatusSuccess(_ context.Context, _ uuid.UUID, status entities.OrderStatus, payment entities.PaymentStatus) error { - r.statusUpdates++ - r.hookCallsAtPay = len(r.hook.calls) - r.order.Status, r.order.PaymentStatus = status, payment - return nil -} - -type hookPaymentRepo struct { - PaymentRepository - created []*entities.Payment -} - -func (r *hookPaymentRepo) GetTotalPaidByOrderID(context.Context, uuid.UUID) (float64, error) { - return 0, nil -} - -func (r *hookPaymentRepo) Create(_ context.Context, p *entities.Payment) error { - p.ID = uuid.New() - r.created = append(r.created, p) - return nil -} - -func (r *hookPaymentRepo) GetByID(_ context.Context, id uuid.UUID) (*entities.Payment, error) { - for _, p := range r.created { - if p.ID == id { - return p, nil - } - } - return nil, nil -} - -func (r *hookPaymentRepo) GetByOrderID(context.Context, uuid.UUID) ([]*entities.Payment, error) { - return r.created, nil -} - -type hookPaymentMethodRepo struct{} - -func (hookPaymentMethodRepo) GetByID(_ context.Context, id uuid.UUID) (*entities.PaymentMethod, error) { - return &entities.PaymentMethod{ID: id}, nil -} - -type hookOrderItemRepo struct{ OrderItemRepository } - -func (hookOrderItemRepo) GetByOrderID(context.Context, uuid.UUID) ([]*entities.OrderItem, error) { - return nil, nil -} - -// splitFake settles the order on the payment that covers what is left, as the real -// split bill processor does. -type splitFake struct{ settle bool } - -func (f splitFake) split(order *entities.Order) (*models.SplitBillResponse, error) { - if f.settle { - order.PaymentStatus = entities.PaymentStatusCompleted - } else { - order.PaymentStatus = entities.PaymentStatusPartial - } - return &models.SplitBillResponse{OrderID: order.ID}, nil -} - -func (f splitFake) SplitByAmount(_ context.Context, _ *models.SplitBillRequest, order *entities.Order, _ *entities.PaymentMethod, _ *entities.Customer) (*models.SplitBillResponse, error) { - return f.split(order) -} - -func (f splitFake) SplitByItem(_ context.Context, _ *models.SplitBillRequest, order *entities.Order, _ *entities.PaymentMethod, _ *entities.Customer) (*models.SplitBillResponse, error) { - return f.split(order) -} - -type orderPaidHookFake struct { - calls []uuid.UUID - ctxs []context.Context - refunded []uuid.UUID - earned map[uuid.UUID]OrderEarned -} - -func (h *orderPaidHookFake) OnOrderPaid(ctx context.Context, orderID uuid.UUID) { - h.calls = append(h.calls, orderID) - h.ctxs = append(h.ctxs, ctx) -} - -func (h *orderPaidHookFake) OnOrderRefunded(_ context.Context, orderID uuid.UUID) { - h.refunded = append(h.refunded, orderID) -} - -func (h *orderPaidHookFake) EarnedByOrders(context.Context, []uuid.UUID) (map[uuid.UUID]OrderEarned, error) { - return h.earned, nil -} - -func newHookedOrderProcessor(split SplitBillProcessor) (*OrderProcessorImpl, *hookOrderRepo, *orderPaidHookFake) { - hook := &orderPaidHookFake{} - orders := &hookOrderRepo{ - order: &entities.Order{ID: uuid.New(), OrganizationID: uuid.New(), OutletID: uuid.New(), TotalAmount: 100000, PaymentStatus: entities.PaymentStatusPending}, - hook: hook, - } - p := &OrderProcessorImpl{ - orderRepo: orders, - orderItemRepo: hookOrderItemRepo{}, - paymentRepo: &hookPaymentRepo{}, - paymentMethodRepo: hookPaymentMethodRepo{}, - splitBillProcessor: split, - txManager: repository.NewTxManager(nil), - } - p.SetLoyalty(hook) - return p, orders, hook -} - -func TestOrderPaidHook_CreatePayment(t *testing.T) { - p, orders, hook := newHookedOrderProcessor(nil) - ctx, cancel := context.WithCancel(context.Background()) - - _, err := p.CreatePayment(ctx, &models.CreatePaymentRequest{OrderID: orders.order.ID, PaymentMethodID: uuid.New(), Amount: 100000}) - require.NoError(t, err) - assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) - assert.Zero(t, orders.hookCallsAtPay, "the hook runs after the payment, not inside its transaction") - - // The hook's context outlives the request. - cancel() - assert.NoError(t, hook.ctxs[0].Err()) -} - -func TestOrderPaidHook_UpdateOrder(t *testing.T) { - p, orders, hook := newHookedOrderProcessor(nil) - _, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{}) - require.NoError(t, err) - assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) - assert.Equal(t, 1, orders.statusUpdates) - assert.Zero(t, orders.hookCallsAtPay) -} - -func TestOrderPaidHook_SplitBillOnlyOnTheSettlingPayment(t *testing.T) { - for _, splitType := range []string{"AMOUNT", "ITEM"} { - t.Run(splitType, func(t *testing.T) { - req := &models.SplitBillRequest{Type: splitType, PaymentMethodID: uuid.New()} - - p, orders, hook := newHookedOrderProcessor(splitFake{settle: false}) - req.OrderID = orders.order.ID - _, err := p.SplitBill(context.Background(), req) - require.NoError(t, err) - assert.Empty(t, hook.calls, "a partial split payment does not make the order paid") - - p, orders, hook = newHookedOrderProcessor(splitFake{settle: true}) - req.OrderID = orders.order.ID - _, err = p.SplitBill(context.Background(), req) - require.NoError(t, err) - assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls) - }) - } -} - -func TestOrderPaidHook_NoHookIsFine(t *testing.T) { - p, orders, _ := newHookedOrderProcessor(nil) - p.SetLoyalty(nil) - _, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{}) - assert.NoError(t, err) -} - -func (r *hookOrderRepo) VoidOrderWithStatus(_ context.Context, _ uuid.UUID, status entities.OrderStatus, _ string, _ uuid.UUID) error { - r.order.Status, r.order.IsVoid = status, true - return nil -} - -func (r *hookOrderRepo) Update(_ context.Context, o *entities.Order) error { - r.order.RefundAmount = o.RefundAmount - return nil -} - -func (r *hookOrderRepo) RefundOrder(context.Context, uuid.UUID, string, uuid.UUID) error { - r.order.IsRefund = true - return nil -} - -func (r *hookPaymentRepo) RefundPayment(_ context.Context, id uuid.UUID, amount float64, _ string, _ uuid.UUID) error { - for _, p := range r.created { - if p.ID == id { - p.RefundAmount = amount - } - } - return nil -} - -func TestOrderRefundedHook(t *testing.T) { - t.Run("void", func(t *testing.T) { - p, orders, hook := newHookedOrderProcessor(nil) - require.NoError(t, p.VoidOrder(context.Background(), &models.VoidOrderRequest{OrderID: orders.order.ID, Type: "ALL", Reason: "salah input"}, uuid.New())) - assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded) - }) - - t.Run("refund order", func(t *testing.T) { - p, orders, hook := newHookedOrderProcessor(nil) - orders.order.PaymentStatus = entities.PaymentStatusCompleted - amount := 25000.0 - require.NoError(t, p.RefundOrder(context.Background(), orders.order.ID, &models.RefundOrderRequest{RefundAmount: &amount}, uuid.New())) - assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded) - }) - - t.Run("refund payment", func(t *testing.T) { - p, orders, hook := newHookedOrderProcessor(nil) - payments := p.paymentRepo.(*hookPaymentRepo) - payment := &entities.Payment{ID: uuid.New(), OrderID: orders.order.ID, Amount: 50000, Status: entities.PaymentTransactionStatusCompleted} - payments.created = append(payments.created, payment) - require.NoError(t, p.RefundPayment(context.Background(), payment.ID, 20000, "kembali", uuid.New())) - assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded) - - // A refund that fails does not run the hook. - assert.Error(t, p.RefundPayment(context.Background(), payment.ID, 60000, "terlalu besar", uuid.New())) - assert.Len(t, hook.refunded, 1) - }) -} - -func (r *hookOrderRepo) List(context.Context, map[string]interface{}, int, int) ([]*entities.Order, int64, error) { - o := *r.order - other := entities.Order{ID: uuid.New()} - return []*entities.Order{&o, &other}, 2, nil -} - -func TestOrderResponsesCarryEarnings(t *testing.T) { - p, orders, hook := newHookedOrderProcessor(nil) - hook.earned = map[uuid.UUID]OrderEarned{orders.order.ID: {Points: 875, Coins: 3}} - - got, err := p.GetOrderByID(context.Background(), orders.order.ID) - require.NoError(t, err) - assert.Equal(t, int64(875), got.PointsEarned) - assert.Equal(t, int64(3), got.CoinsEarned) - - list, err := p.ListOrders(context.Background(), &models.ListOrdersRequest{Page: 1, Limit: 10}) - require.NoError(t, err) - require.Len(t, list.Orders, 2) - assert.Equal(t, int64(875), list.Orders[0].PointsEarned) - assert.Equal(t, int64(0), list.Orders[1].PointsEarned, "an order that earned nothing shows 0") - - // Without the loyalty program the fields stay at 0. - p.SetLoyalty(nil) - got, err = p.GetOrderByID(context.Background(), orders.order.ID) - require.NoError(t, err) - assert.Equal(t, int64(0), got.PointsEarned) -} diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index bea796c..3e14e23 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -5,10 +5,8 @@ import ( "errors" "fmt" - "apskel-pos-be/internal/appcontext" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/logger" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" @@ -21,9 +19,6 @@ type OrderProcessor interface { CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error) - // PayWithPointsInApp pays the customer's own order with EnakPoint from the app or a - // self-order, approved by their PIN (docs/prd-point-coin.md F9). - PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error) VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error @@ -114,139 +109,6 @@ type OrderProcessorImpl struct { ingredientRepo IngredientRepository inventoryMovementService InventoryMovementService productOutletPriceRepo repository.ProductOutletPriceRepository - loyalty OrderLoyalty - pointPayments *PointPaymentProcessor - paymentCodes paymentCodeRedeemer - pins pinVerifier -} - -// OrderLoyalty is what the order flow tells and asks the loyalty program -// (docs/prd-point-coin.md F3, F10). EarningProcessor is one. -type OrderLoyalty interface { - // OnOrderPaid runs once an order has just become fully paid and the payment has - // committed. - OnOrderPaid(ctx context.Context, orderID uuid.UUID) - // OnOrderRefunded runs once an order has been voided or (partly) refunded and that - // has committed. - OnOrderRefunded(ctx context.Context, orderID uuid.UUID) - // EarnedByOrders returns what each order earned. - EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error) -} - -// OrderEarned is what one order earned, as written in its EARN rows. -type OrderEarned struct { - Points int64 - Coins int64 -} - -// SetLoyalty connects the order flow to the loyalty program. -func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) { - p.loyalty = loyalty -} - -type paymentCodeRedeemer interface { - Redeem(ctx context.Context, code string, customerID uuid.UUID) error -} - -// SetPointPayments enables paying with the EnakPoint method. CreatePayment hands such -// payments to pointPayments approved by the code the customer shows at the cashier, -// and PayWithPointsInApp approved by the customer's PIN (F9). -func (p *OrderProcessorImpl) SetPointPayments(pointPayments *PointPaymentProcessor, codes paymentCodeRedeemer, pins pinVerifier) { - p.pointPayments = pointPayments - p.paymentCodes = codes - p.pins = pins -} - -// createPointPayment is CreatePayment for the EnakPoint method. It never uses the -// generic payment path, which would record the payment without taking any balance. -func (p *OrderProcessorImpl) createPointPayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) { - if p.pointPayments == nil || p.paymentCodes == nil { - return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected) - } - if req.Points == nil || req.PaymentCode == nil || *req.PaymentCode == "" { - return nil, fmt.Errorf("%w: points and the customer's payment code are required", ErrPointPaymentRejected) - } - var cashier *uuid.UUID - if id := appcontext.FromContext(ctx).UserID; id != uuid.Nil { - cashier = &id - } - code := *req.PaymentCode - result, err := p.pointPayments.Pay(ctx, PointPaymentInput{ - OrderID: req.OrderID, - PaymentMethodID: req.PaymentMethodID, - Points: *req.Points, - CashierID: cashier, - Authorize: func(ctx context.Context, customerID uuid.UUID) error { - if err := p.paymentCodes.Redeem(ctx, code, customerID); err != nil { - return fmt.Errorf("%w: %v", ErrPointPaymentRejected, err) - } - return nil - }, - }) - if err != nil { - return nil, err - } - if result.Completed { - p.onOrderPaid(ctx, req.OrderID) - } - payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID) - if err != nil { - return nil, fmt.Errorf("failed to retrieve created payment: %w", err) - } - return mappers.PaymentEntityToResponse(payment), nil -} - -// onOrderPaid is the single place every path that completes an order's payment goes -// through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called -// after the payment has committed. The hook runs detached from the caller's -// transaction and from the request being cancelled, and anything it does cannot fail -// the payment. -func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID) { - if p.loyalty == nil { - return - } - p.loyalty.OnOrderPaid(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID) -} - -// onOrderRefunded is called at the end of VoidOrder, RefundOrder and RefundPayment, -// after their writes have committed, with the same guarantees as onOrderPaid: it can -// never block or fail the void or refund. -func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) { - ctx = repository.DetachTransaction(context.WithoutCancel(ctx)) - // EnakPoint paid on the order comes back first: the customer is owed it (F9). - if p.pointPayments != nil { - if _, err := p.pointPayments.RefundForOrder(ctx, orderID); err != nil { - logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::onOrderRefunded -> failed to return EnakPoint; calling it again is safe") - } - } - if p.loyalty != nil { - p.loyalty.OnOrderRefunded(ctx, orderID) - } -} - -// attachEarnings fills points_earned and coins_earned (F3). A failure is logged and -// leaves them at zero rather than failing the order read. -func (p *OrderProcessorImpl) attachEarnings(ctx context.Context, responses ...*models.OrderResponse) { - if p.loyalty == nil || len(responses) == 0 { - return - } - ids := make([]uuid.UUID, 0, len(responses)) - for _, r := range responses { - if r != nil { - ids = append(ids, r.ID) - } - } - earned, err := p.loyalty.EarnedByOrders(repository.DetachTransaction(ctx), ids) - if err != nil { - logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::attachEarnings -> failed to read earnings") - return - } - for _, r := range responses { - if r != nil { - e := earned[r.ID] - r.PointsEarned, r.CoinsEarned = e.Points, e.Coins - } - } } func NewOrderProcessorImpl( @@ -435,7 +297,6 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create } response := mappers.OrderEntityToResponse(orderWithRelations) - p.attachEarnings(ctx, response) return response, nil } @@ -574,7 +435,6 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, } updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations) - p.attachEarnings(ctx, updatedOrderResponse) return &models.AddToOrderResponse{ OrderID: orderID, @@ -634,7 +494,6 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req if err := p.orderRepo.UpdateStatusSuccess(ctx, order.ID, order.Status, order.PaymentStatus); err != nil { return nil, fmt.Errorf("failed to update order: %w", err) } - p.onOrderPaid(ctx, order.ID) orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, id) if err != nil { @@ -642,7 +501,6 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req } response := mappers.OrderEntityToResponse(orderWithRelations) - p.attachEarnings(ctx, response) return response, nil } @@ -653,7 +511,6 @@ func (p *OrderProcessorImpl) GetOrderByID(ctx context.Context, id uuid.UUID) (*m } response := mappers.OrderEntityToResponse(order) - p.attachEarnings(ctx, response) return response, nil } @@ -715,11 +572,6 @@ func (p *OrderProcessorImpl) ListOrders(ctx context.Context, req *models.ListOrd } } } - listed := make([]*models.OrderResponse, len(orderResponses)) - for i := range orderResponses { - listed[i] = &orderResponses[i] - } - p.attachEarnings(ctx, listed...) totalPages := int(total) / req.Limit if int(total)%req.Limit > 0 { @@ -840,10 +692,6 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde return fmt.Errorf("invalid void type: must be 'ALL' or 'ITEM'") } - // VoidOrder refuses fully paid orders today, so there is usually nothing to take - // back; this keeps a void correct if that rule changes. The order repository writes - // outside the caller's transaction, so the void is already committed here. - p.onOrderRefunded(ctx, req.OrderID) return nil } @@ -866,18 +714,6 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req reason = *req.Reason } - // An order refund is handed back in cash or another method, so it cannot cover what - // was paid with EnakPoint (K7). Checked before anything is written. - if p.pointPayments != nil { - planned, err := p.plannedOrderRefund(ctx, id, req) - if err != nil { - return err - } - if err := p.pointPayments.EnsureOrderRefundAllowed(ctx, id, planned); err != nil { - return err - } - } - // Process refund based on request type if req.RefundAmount != nil { // Full or partial refund by amount @@ -951,7 +787,6 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req } } - p.onOrderRefunded(ctx, id) return nil } @@ -969,13 +804,10 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea return nil, fmt.Errorf("order is already fully paid") } - method, err := p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID) + _, err = p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID) if err != nil { return nil, fmt.Errorf("payment method not found: %w", err) } - if method.Type == entities.PaymentMethodTypePoint { - return p.createPointPayment(ctx, req) - } totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, req.OrderID) if err != nil { @@ -986,8 +818,6 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea if err != nil { return nil, err } - // Not from updateOrderStatus: that runs inside the payment's transaction. - p.onOrderPaid(ctx, req.OrderID) paymentWithRelations, err := p.paymentRepo.GetByID(ctx, payment.ID) if err != nil { @@ -1012,11 +842,7 @@ func (p *OrderProcessorImpl) RefundPayment(ctx context.Context, paymentID uuid.U return fmt.Errorf("refund amount cannot exceed payment amount") } - if err := p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment); err != nil { - return err - } - p.onOrderRefunded(ctx, payment.OrderID) - return nil + return p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment) } func (p *OrderProcessorImpl) CreatePaymentWithInventoryMovement(ctx context.Context, req *models.CreatePaymentRequest, order *entities.Order, totalPaid float64) (*entities.Payment, error) { @@ -1360,11 +1186,6 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil if err != nil { return nil, fmt.Errorf("payment method not found: %w", err) } - // Splitting with EnakPoint would record a payment without taking any balance; an - // EnakPoint part goes through CreatePayment and the rest is split as usual (F9). - if payment.Type == entities.PaymentMethodTypePoint { - return nil, fmt.Errorf("%w: pay the EnakPoint part as its own payment, not as a split", ErrPointPaymentRejected) - } customer := &entities.Customer{} if req.CustomerID != uuid.Nil { @@ -1386,10 +1207,6 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil if err != nil { return nil, err } - // Both split paths mark the order paid on the payment that settles it. - if order.PaymentStatus == entities.PaymentStatusCompleted { - p.onOrderPaid(ctx, order.ID) - } return response, nil } @@ -1811,71 +1628,3 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Con func stringPtr(s string) *string { return &s } - -// PayWithPointsInApp pays an order with EnakPoint on the customer's own request, in the -// app or a self-order. The session alone is not enough: the customer's PIN approves -// it (K8). An order that is not the customer's own is reported as not found, so the -// endpoint does not reveal other customers' orders. -func (p *OrderProcessorImpl) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) { - if p.pointPayments == nil || p.pins == nil { - return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected) - } - organizationID, owner, err := p.pointPayments.OrderOwner(ctx, orderID) - if err != nil { - return nil, err - } - if owner == nil || *owner != customerID { - return nil, repository.ErrPointPaymentOrderNotFound - } - methodID, err := p.pointPayments.PointMethodID(ctx, organizationID) - if err != nil { - return nil, err - } - result, err := p.pointPayments.Pay(ctx, PointPaymentInput{ - OrderID: orderID, - PaymentMethodID: methodID, - Points: points, - Authorize: func(ctx context.Context, customerID uuid.UUID) error { - return p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info) - }, - }) - if err != nil { - return nil, err - } - if result.Completed { - p.onOrderPaid(ctx, orderID) - } - payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID) - if err != nil { - return nil, fmt.Errorf("failed to retrieve created payment: %w", err) - } - return mappers.PaymentEntityToResponse(payment), nil -} - -// plannedOrderRefund is the total RefundOrder is about to hand back, computed the same -// way it will be, without writing anything. -func (p *OrderProcessorImpl) plannedOrderRefund(ctx context.Context, orderID uuid.UUID, req *models.RefundOrderRequest) (float64, error) { - if req.RefundAmount != nil { - return *req.RefundAmount, nil - } - var total float64 - for _, itemRefund := range req.OrderItems { - if itemRefund.RefundAmount != nil { - total += *itemRefund.RefundAmount - continue - } - orderItem, err := p.orderItemRepo.GetByID(ctx, itemRefund.OrderItemID) - if err != nil { - return 0, fmt.Errorf("order item not found: %w", err) - } - if orderItem.OrderID != orderID { - return 0, fmt.Errorf("order item does not belong to this order") - } - quantity := itemRefund.RefundQuantity - if quantity == 0 { - quantity = orderItem.Quantity - } - total += entities.RoundMoney(orderItem.BillableQuantityFor(quantity) * orderItem.UnitPrice) - } - return total, nil -} diff --git a/internal/processor/otp_processor.go b/internal/processor/otp_processor.go index 28013aa..f0616ed 100644 --- a/internal/processor/otp_processor.go +++ b/internal/processor/otp_processor.go @@ -133,10 +133,6 @@ func (p *otpProcessor) SendOtpViaWhatsApp(phoneNumber string, otpCode string, pu switch purpose { case "login": message = fmt.Sprintf("Kode OTP untuk login kamu adalah %s. Berlaku 5 menit.", otpCode) - case "pin_setup": - message = fmt.Sprintf("Kode OTP untuk membuat PIN EnakPoint kamu adalah %s. Berlaku 5 menit. Jangan berikan kode ini kepada siapa pun, termasuk kasir.", otpCode) - case "pin_reset": - message = fmt.Sprintf("Kode OTP untuk reset PIN EnakPoint kamu adalah %s. Berlaku 5 menit. Jangan berikan kode ini kepada siapa pun. Setelah reset, transfer ditahan 24 jam.", otpCode) case "registration": message = fmt.Sprintf("Kode OTP untuk registrasi kamu adalah %s. Berlaku 5 menit.", otpCode) default: diff --git a/internal/processor/payment_code_processor.go b/internal/processor/payment_code_processor.go deleted file mode 100644 index 7c7038c..0000000 --- a/internal/processor/payment_code_processor.go +++ /dev/null @@ -1,101 +0,0 @@ -package processor - -import ( - "context" - "crypto/rand" - "errors" - "fmt" - "math/big" - "strings" - "time" - - "github.com/google/uuid" - - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -const ( - paymentCodeDigits = 6 - paymentCodeTTL = 2 * time.Minute - paymentCodeAttempts = 5 - // PaymentCodeQRPrefix marks a scanned QR as an EnakPoint payment code. - PaymentCodeQRPrefix = "enakpoint:" -) - -// ErrPaymentCodeInvalid means the code was never issued, has expired, has been used, -// or belongs to another customer. -var ErrPaymentCodeInvalid = errors.New("payment code is invalid or expired") - -type pinVerifier interface { - VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error -} - -// PaymentCodeProcessor issues and redeems the one-time codes that let a cashier take a -// customer's EnakPoint (docs/prd-point-coin.md F9, K8). The customer approves with -// their PIN on their own phone and shows the code; the PIN is never typed on the -// cashier's device. -type PaymentCodeProcessor struct { - codes repository.PaymentCodeRepository - pins pinVerifier - now func() time.Time -} - -func NewPaymentCodeProcessor(codes repository.PaymentCodeRepository, pins pinVerifier) *PaymentCodeProcessor { - return &PaymentCodeProcessor{codes: codes, pins: pins, now: time.Now} -} - -// Issue checks the customer's PIN and returns a fresh 6-digit code, valid for two -// minutes and bound to the customer. A new code retires the previous one. -func (p *PaymentCodeProcessor) Issue(ctx context.Context, customerID uuid.UUID, pin string, info models.CustomerPinRequestInfo) (*models.PaymentCode, error) { - if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info); err != nil { - return nil, err - } - for attempt := 0; attempt < paymentCodeAttempts; attempt++ { - code, err := randomDigits(paymentCodeDigits) - if err != nil { - return nil, err - } - err = p.codes.Save(ctx, code, customerID, paymentCodeTTL) - if errors.Is(err, repository.ErrPaymentCodeTaken) { - continue - } - if err != nil { - return nil, err - } - return &models.PaymentCode{ - Code: code, - QRPayload: PaymentCodeQRPrefix + code, - ExpiresAt: p.now().Add(paymentCodeTTL), - }, nil - } - return nil, fmt.Errorf("could not draw a free payment code after %d attempts", paymentCodeAttempts) -} - -// Redeem uses a code up for a payment by the given customer. It accepts the code as -// typed or as scanned from the QR. Every failure is ErrPaymentCodeInvalid. -func (p *PaymentCodeProcessor) Redeem(ctx context.Context, code string, customerID uuid.UUID) error { - code = strings.TrimPrefix(strings.TrimSpace(code), PaymentCodeQRPrefix) - if len(code) != paymentCodeDigits { - return ErrPaymentCodeInvalid - } - err := p.codes.Consume(ctx, code, customerID) - if errors.Is(err, repository.ErrPaymentCodeNotFound) || errors.Is(err, repository.ErrPaymentCodeWrongCustomer) { - return ErrPaymentCodeInvalid - } - return err -} - -// randomDigits draws n decimal digits from a cryptographic source, so codes cannot be -// predicted. -func randomDigits(n int) (string, error) { - var b strings.Builder - for i := 0; i < n; i++ { - d, err := rand.Int(rand.Reader, big.NewInt(10)) - if err != nil { - return "", fmt.Errorf("failed to draw a payment code: %w", err) - } - b.WriteByte(byte('0' + d.Int64())) - } - return b.String(), nil -} diff --git a/internal/processor/payment_code_processor_test.go b/internal/processor/payment_code_processor_test.go deleted file mode 100644 index bb134d7..0000000 --- a/internal/processor/payment_code_processor_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package processor - -import ( - "context" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/alicebob/miniredis/v2" - "github.com/google/uuid" - "github.com/redis/go-redis/v9" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -type pinVerifierFake struct{ good string } - -func (f pinVerifierFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error { - if action != PinActionPay { - return &PinError{Code: "UNEXPECTED_ACTION"} - } - if pin != f.good { - return &PinError{Code: PinErrInvalid, RemainingAttempts: 4} - } - return nil -} - -func newPaymentCodeTest(t *testing.T) (*PaymentCodeProcessor, *miniredis.Miniredis) { - t.Helper() - mr := miniredis.RunT(t) - client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - t.Cleanup(func() { client.Close() }) - return NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"}), mr -} - -func TestPaymentCode_IssueNeedsThePin(t *testing.T) { - p, mr := newPaymentCodeTest(t) - _, err := p.Issue(context.Background(), uuid.New(), "000000", models.CustomerPinRequestInfo{}) - var pe *PinError - require.ErrorAs(t, err, &pe) - assert.Equal(t, PinErrInvalid, pe.Code) - assert.Empty(t, mr.Keys(), "nothing is issued without the PIN") -} - -func TestPaymentCode_Lifecycle(t *testing.T) { - p, mr := newPaymentCodeTest(t) - ctx := context.Background() - customer, other := uuid.New(), uuid.New() - - code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - assert.Len(t, code.Code, 6) - assert.Equal(t, "enakpoint:"+code.Code, code.QRPayload) - assert.WithinDuration(t, time.Now().Add(2*time.Minute), code.ExpiresAt, 2*time.Second) - assert.InDelta(t, 120, mr.TTL("wallet:paycode:"+code.Code).Seconds(), 1, "Redis expires it by itself") - - // A code of another customer is refused, and stays usable by its owner. - assert.ErrorIs(t, p.Redeem(ctx, code.Code, other), ErrPaymentCodeInvalid) - // Scanned from the QR it works; used once, it is gone. - require.NoError(t, p.Redeem(ctx, code.QRPayload, customer)) - assert.ErrorIs(t, p.Redeem(ctx, code.Code, customer), ErrPaymentCodeInvalid) - - // An expired code is refused. - late, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - mr.FastForward(2*time.Minute + time.Second) - assert.ErrorIs(t, p.Redeem(ctx, late.Code, customer), ErrPaymentCodeInvalid) - - // A new code retires the previous one. - first, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - second, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - if first.Code != second.Code { - assert.ErrorIs(t, p.Redeem(ctx, first.Code, customer), ErrPaymentCodeInvalid) - } - require.NoError(t, p.Redeem(ctx, second.Code, customer)) - - // Garbage is refused without touching Redis. - for _, bad := range []string{"", "12345", "1234567", "enakpoint:"} { - assert.ErrorIs(t, p.Redeem(ctx, bad, customer), ErrPaymentCodeInvalid, bad) - } -} - -// Two cashiers scanning the same code at once: exactly one gets it. -func TestPaymentCode_UsedOnceUnderRace(t *testing.T) { - p, _ := newPaymentCodeTest(t) - ctx := context.Background() - customer := uuid.New() - code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - - var wins int32 - var wg sync.WaitGroup - for i := 0; i < 20; i++ { - wg.Add(1) - go func() { - defer wg.Done() - if p.Redeem(ctx, code.Code, customer) == nil { - atomic.AddInt32(&wins, 1) - } - }() - } - wg.Wait() - assert.Equal(t, int32(1), wins) -} - -func TestPaymentCode_SaveRefusesALiveCode(t *testing.T) { - mr := miniredis.RunT(t) - client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - defer client.Close() - repo := repository.NewPaymentCodeRepository(client) - ctx := context.Background() - - require.NoError(t, repo.Save(ctx, "123456", uuid.New(), time.Minute)) - assert.ErrorIs(t, repo.Save(ctx, "123456", uuid.New(), time.Minute), repository.ErrPaymentCodeTaken, - "a live code is never handed to a second customer") -} - -func TestRandomDigits(t *testing.T) { - seen := map[string]bool{} - for i := 0; i < 200; i++ { - d, err := randomDigits(6) - require.NoError(t, err) - require.Len(t, d, 6) - for _, r := range d { - require.True(t, r >= '0' && r <= '9') - } - seen[d] = true - } - assert.Greater(t, len(seen), 190, "codes do not repeat") -} diff --git a/internal/processor/payment_method_processor.go b/internal/processor/payment_method_processor.go index bd0b240..952ab86 100644 --- a/internal/processor/payment_method_processor.go +++ b/internal/processor/payment_method_processor.go @@ -1,10 +1,7 @@ package processor import ( - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" "context" - "errors" "fmt" "apskel-pos-be/internal/mappers" @@ -23,26 +20,17 @@ type PaymentMethodProcessor interface { GetActivePaymentMethodsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]models.PaymentMethodResponse, error) } -// ErrSystemPaymentMethod means an attempt to create, delete or retype the EnakPoint -// method, which the system owns (docs/prd-point-coin.md F9). -var ErrSystemPaymentMethod = errors.New("the EnakPoint payment method is managed by the system: it cannot be created, deleted or change type") - type PaymentMethodProcessorImpl struct { paymentMethodRepo repository.PaymentMethodRepository - outletSettings outletSettingsReader } -func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository, outletSettings outletSettingsReader) *PaymentMethodProcessorImpl { +func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository) *PaymentMethodProcessorImpl { return &PaymentMethodProcessorImpl{ paymentMethodRepo: paymentMethodRepo, - outletSettings: outletSettings, } } func (p *PaymentMethodProcessorImpl) CreatePaymentMethod(ctx context.Context, req *models.CreatePaymentMethodRequest) (*models.PaymentMethodResponse, error) { - if req.Type == constants.PaymentMethodTypePoint { - return nil, ErrSystemPaymentMethod - } exists, err := p.paymentMethodRepo.ExistsByName(ctx, req.OrganizationID, req.Name, nil) if err != nil { return nil, fmt.Errorf("failed to check payment method name uniqueness: %w", err) @@ -89,17 +77,6 @@ func (p *PaymentMethodProcessorImpl) ListPaymentMethods(ctx context.Context, req if req.Search != "" { filters["search"] = req.Search } - // At the cashier EnakPoint only shows where the outlet accepts it (F9). Filtered in - // the query so paging stays right. - if req.OutletID != nil && p.outletSettings != nil { - settings, err := p.outletSettings.Outlet(ctx, *req.OutletID) - if err != nil { - return nil, fmt.Errorf("failed to read outlet loyalty settings: %w", err) - } - if !settings.PointPayment.AcceptPayment { - filters["exclude_type"] = string(constants.PaymentMethodTypePoint) - } - } offset := (req.Page - 1) * req.Limit @@ -139,15 +116,6 @@ func (p *PaymentMethodProcessorImpl) UpdatePaymentMethod(ctx context.Context, id return nil, fmt.Errorf("payment method not found: %w", err) } - // The EnakPoint method keeps its type, and no other method can become one. - if req.Type != nil { - wasPoint := existingPaymentMethod.Type == entities.PaymentMethodTypePoint - isPoint := *req.Type == constants.PaymentMethodTypePoint - if wasPoint != isPoint { - return nil, ErrSystemPaymentMethod - } - } - // Check name uniqueness if name is being updated if req.Name != nil && *req.Name != existingPaymentMethod.Name { exists, err := p.paymentMethodRepo.ExistsByName(ctx, existingPaymentMethod.OrganizationID, *req.Name, &id) @@ -179,13 +147,10 @@ func (p *PaymentMethodProcessorImpl) UpdatePaymentMethod(ctx context.Context, id func (p *PaymentMethodProcessorImpl) DeletePaymentMethod(ctx context.Context, id uuid.UUID) error { // Check if payment method exists - existing, err := p.paymentMethodRepo.GetByID(ctx, id) + _, err := p.paymentMethodRepo.GetByID(ctx, id) if err != nil { return fmt.Errorf("payment method not found: %w", err) } - if existing.Type == entities.PaymentMethodTypePoint { - return ErrSystemPaymentMethod - } // TODO: Check if payment method is being used in any payments // For now, allow deletion diff --git a/internal/processor/point_payment_db_test.go b/internal/processor/point_payment_db_test.go deleted file mode 100644 index 3ab8c2f..0000000 --- a/internal/processor/point_payment_db_test.go +++ /dev/null @@ -1,401 +0,0 @@ -package processor - -import ( - "context" - "os" - "sync" - "testing" - "time" - - "github.com/alicebob/miniredis/v2" - "github.com/google/uuid" - "github.com/redis/go-redis/v9" - "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/appcontext" - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// pointPaymentEnv is an order flow wired as in the app, against Postgres and a -// miniredis for payment codes. -type pointPaymentEnv struct { - t *testing.T - db *gorm.DB - orders *OrderProcessorImpl - payments *PointPaymentProcessor - codes *PaymentCodeProcessor - org uuid.UUID - cashier uuid.UUID - outlet uuid.UUID - point uuid.UUID - cash uuid.UUID - walkIn uuid.UUID - ctx context.Context -} - -func newPointPaymentEnv(t *testing.T) *pointPaymentEnv { - t.Helper() - 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) - e := &pointPaymentEnv{t: t, db: db, org: uuid.New(), cashier: uuid.New(), outlet: uuid.New(), cash: uuid.New()} - e.exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'point pay test', 'basic')`, e.org) - e.exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, e.cashier, e.org, e.cashier.String()+"@t") - e.exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, e.outlet, e.org) - e.exec(`INSERT INTO payment_methods (id, organization_id, name, type) VALUES (?, ?, 'Tunai', 'cash')`, e.cash, e.org) - var ids []string - require.NoError(t, db.Raw(`SELECT id::text FROM payment_methods WHERE organization_id = ? AND type = 'point'`, e.org).Scan(&ids).Error) - require.Len(t, ids, 1) - e.point = uuid.MustParse(ids[0]) - require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, e.org).Scan(&ids).Error) - e.walkIn = uuid.MustParse(ids[0]) - t.Cleanup(func() { - db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, e.org) - db.Exec(`DELETE FROM wallet_lots WHERE organization_id = ?`, e.org) - db.Exec(`DELETE FROM wallet_transactions WHERE organization_id = ?`, e.org) - db.Exec(`DELETE FROM customer_wallets WHERE organization_id = ?`, e.org) - db.Exec(`DELETE FROM payments WHERE order_id IN (SELECT id FROM orders WHERE organization_id = ?)`, e.org) - db.Exec(`DELETE FROM orders WHERE organization_id = ?`, e.org) - db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, e.org) - db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, e.outlet) - db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, e.org) - db.Exec(`DELETE FROM customers WHERE organization_id = ?`, e.org) - db.Exec(`DELETE FROM outlets WHERE id = ?`, e.outlet) - db.Exec(`DELETE FROM users WHERE id = ?`, e.cashier) - db.Exec(`DELETE FROM organizations WHERE id = ?`, e.org) - }) - - txm := repository.NewTxManager(db) - settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) - wallet := NewWalletProcessor(repository.NewWalletRepository(db)) - e.payments = NewPointPaymentProcessor(repository.NewPointPaymentRepository(db), settings, repository.NewWalletQueryRepository(db), wallet, txm) - mr := miniredis.RunT(t) - client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - t.Cleanup(func() { client.Close() }) - e.codes = NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"}) - - e.orders = &OrderProcessorImpl{ - orderRepo: repository.NewOrderRepositoryImpl(db), - orderItemRepo: repository.NewOrderItemRepositoryImpl(db), - paymentRepo: repository.NewPaymentRepositoryImpl(db), - paymentMethodRepo: repository.NewPaymentMethodRepositoryImpl(db), - splitBillProcessor: nil, - txManager: txm, - } - e.orders.SetLoyalty(NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm)) - e.orders.SetPointPayments(e.payments, e.codes, pinVerifierFake{good: "482913"}) - - // The outlet earns 1 EnakPoint per Rp 100 and accepts EnakPoint. - s, err := settings.Outlet(context.Background(), e.outlet) - require.NoError(t, err) - s.Point.Enabled = true - s.PointPayment.AcceptPayment = true - _, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s) - require.NoError(t, err) - - e.ctx = context.WithValue(context.Background(), appcontext.UserIDKey, e.cashier.String()) - return e -} - -func (e *pointPaymentEnv) exec(q string, args ...any) { - e.t.Helper() - require.NoError(e.t, e.db.Exec(q, args...).Error) -} - -// customerWith creates a customer holding the given EnakPoint. -func (e *pointPaymentEnv) customerWith(points int64) uuid.UUID { - e.t.Helper() - id := uuid.New() - e.exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, id, e.org) - if points > 0 { - require.NoError(e.t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error { - _, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{ - CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: points, - ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}}) - return err - })) - } - return id -} - -func (e *pointPaymentEnv) order(customer uuid.UUID, subtotal float64) uuid.UUID { - e.t.Helper() - id := uuid.New() - e.exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type, - subtotal, tax_amount, total_amount, remaining_amount, payment_status) - VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, 0, ?, ?, 'pending')`, - id, e.org, e.outlet, e.cashier, customer, "PP-"+id.String()[:8], subtotal, subtotal, subtotal) - return id -} - -func (e *pointPaymentEnv) code(customer uuid.UUID) string { - e.t.Helper() - c, err := e.codes.Issue(context.Background(), customer, "482913", models.CustomerPinRequestInfo{}) - require.NoError(e.t, err) - return c.Code -} - -func (e *pointPaymentEnv) payPoints(order uuid.UUID, points int64, code string) (*models.PaymentResponse, error) { - return e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &points, PaymentCode: &code}) -} - -func (e *pointPaymentEnv) balance(customer uuid.UUID) int64 { - e.t.Helper() - var b int64 - require.NoError(e.t, e.db.Raw(`SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error) - return b -} - -func (e *pointPaymentEnv) orderState(order uuid.UUID) (status string, remaining float64) { - e.t.Helper() - var row struct { - PaymentStatus string - RemainingAmount float64 - } - require.NoError(e.t, e.db.Raw(`SELECT payment_status, remaining_amount FROM orders WHERE id = ?`, order).Scan(&row).Error) - return row.PaymentStatus, row.RemainingAmount -} - -func TestPointPayment_FullPayment(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(100000) - order := e.order(customer, 50000) - - payment, err := e.payPoints(order, 50000, e.code(customer)) - require.NoError(t, err) - assert.Equal(t, 50000.0, payment.Amount) - require.NotNil(t, payment.PointsUsed) - assert.Equal(t, int64(50000), *payment.PointsUsed) - assert.Equal(t, 1.0, *payment.PointValue, "the value is frozen on the payment") - status, remaining := e.orderState(order) - assert.Equal(t, "completed", status) - assert.Zero(t, remaining) - assert.Equal(t, int64(50000), e.balance(customer)) - - var ledger struct { - Amount int64 - ReferenceType string - ReferenceID string - OutletID string - CreatedByUser string - } - require.NoError(t, e.db.Raw(`SELECT amount, reference_type, reference_id::text AS reference_id, outlet_id::text AS outlet_id, - created_by_user::text AS created_by_user FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT'`, customer).Scan(&ledger).Error) - assert.Equal(t, int64(-50000), ledger.Amount) - assert.Equal(t, "PAYMENT", ledger.ReferenceType) - assert.Equal(t, payment.ID.String(), ledger.ReferenceID) - assert.Equal(t, e.outlet.String(), ledger.OutletID) - assert.Equal(t, e.cashier.String(), ledger.CreatedByUser, "the cashier who took it") - - // Paid entirely with EnakPoint, so nothing earns (Q10). - var earned int64 - require.NoError(t, e.db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error) - assert.Zero(t, earned) -} - -func TestPointPayment_PartialThenCash(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(100000) - order := e.order(customer, 87500) - - _, err := e.payPoints(order, 20000, e.code(customer)) - require.NoError(t, err) - status, remaining := e.orderState(order) - assert.Equal(t, "partial", status) - assert.Equal(t, 67500.0, remaining) - - // The rest in cash settles it; earning counts only the part not paid with - // EnakPoint: floor(67.500 / 100) = 675. - _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500}) - require.NoError(t, err) - status, _ = e.orderState(order) - assert.Equal(t, "completed", status) - var earned int64 - require.NoError(t, e.db.Raw(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error) - assert.Equal(t, int64(675), earned) - assert.Equal(t, int64(100000-20000+675), e.balance(customer)) -} - -func TestPointPayment_PercentCap(t *testing.T) { - e := newPointPaymentEnv(t) - settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db)) - s, err := settings.Outlet(context.Background(), e.outlet) - require.NoError(t, err) - s.PointPayment.MaxPaymentPercent = 50 - _, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s) - require.NoError(t, err) - - customer := e.customerWith(100000) - order := e.order(customer, 100000) - - preview, err := e.payments.Preview(context.Background(), e.org, order) - require.NoError(t, err) - assert.True(t, preview.Eligible) - assert.Equal(t, int64(50000), preview.MaxPoints) - assert.Equal(t, int64(100000), preview.PointBalance) - - _, err = e.payPoints(order, 50001, e.code(customer)) - assert.ErrorIs(t, err, ErrPointPaymentRejected) - _, err = e.payPoints(order, 30000, e.code(customer)) - require.NoError(t, err) - _, err = e.payPoints(order, 20001, e.code(customer)) - assert.ErrorIs(t, err, ErrPointPaymentRejected, "earlier EnakPoint counts toward the cap") - _, err = e.payPoints(order, 20000, e.code(customer)) - require.NoError(t, err) - assert.Equal(t, int64(50000), e.balance(customer)) -} - -func TestPointPayment_Refusals(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(100000) - other := e.customerWith(100000) - - // A walk-in order cannot be paid with EnakPoint. - walkInOrder := e.order(e.walkIn, 10000) - preview, err := e.payments.Preview(context.Background(), e.org, walkInOrder) - require.NoError(t, err) - assert.False(t, preview.Eligible) - assert.Contains(t, preview.Reason, "walk-in") - _, err = e.payPoints(walkInOrder, 1000, "123456") - assert.ErrorIs(t, err, ErrPointPaymentRejected) - - order := e.order(customer, 10000) - _, err = e.payPoints(order, 1000, "000000") - assert.ErrorIs(t, err, ErrPointPaymentRejected, "a wrong code") - _, err = e.payPoints(order, 1000, e.code(other)) - assert.ErrorIs(t, err, ErrPointPaymentRejected, "another customer's code") - missing := int64(1000) - _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &missing}) - assert.ErrorIs(t, err, ErrPointPaymentRejected, "no code at all") - - code := e.code(customer) - _, err = e.payPoints(order, 1000, code) - require.NoError(t, err) - _, err = e.payPoints(order, 1000, code) - assert.ErrorIs(t, err, ErrPointPaymentRejected, "a code is used once, so a double tap takes once") - _, err = e.payPoints(order, 9001, e.code(customer)) - assert.ErrorIs(t, err, ErrPointPaymentRejected, "no change is given: not more than what is left") - - // Splitting with the EnakPoint method would skip the balance, so it is refused. - e.orders.splitBillProcessor = splitFake{} - _, err = e.orders.SplitBill(e.ctx, &models.SplitBillRequest{OrderID: order, PaymentMethodID: e.point, Type: "AMOUNT", Amount: 1000}) - assert.ErrorIs(t, err, ErrPointPaymentRejected) - - assert.Equal(t, int64(99000), e.balance(customer), "only the one payment took anything") - assert.Equal(t, int64(100000), e.balance(other)) -} - -// Two payments for the same customer at once, on two orders: the balance is taken -// once, never twice. Authorization is taken as given so only the balance decides. -func TestPointPayment_ConcurrentForOneCustomer(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(30000) - orders := []uuid.UUID{e.order(customer, 20000), e.order(customer, 20000)} - - var wg sync.WaitGroup - results := make([]error, len(orders)) - for i, order := range orders { - wg.Add(1) - go func(i int, order uuid.UUID) { - defer wg.Done() - _, results[i] = e.payments.Pay(e.ctx, PointPaymentInput{ - OrderID: order, PaymentMethodID: e.point, Points: 20000, - Authorize: func(context.Context, uuid.UUID) error { return nil }, - }) - }(i, order) - } - wg.Wait() - - succeeded := 0 - for _, err := range results { - if err == nil { - succeeded++ - } else { - assert.ErrorIs(t, err, ErrPointPaymentRejected) - } - } - assert.Equal(t, 1, succeeded, "30.000 EnakPoint pays one 20.000 order, not two") - assert.Equal(t, int64(10000), e.balance(customer)) - - // And the wallet still reconciles. - found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000) - require.NoError(t, err) - for _, d := range found { - assert.NotEqual(t, customer, d.CustomerID, d.Check) - } -} - -func TestPointPayment_InApp(t *testing.T) { - e := newPointPaymentEnv(t) - owner := e.customerWith(100000) - stranger := e.customerWith(100000) - order := e.order(owner, 60000) - info := models.CustomerPinRequestInfo{} - - // Another customer cannot pay it, and is not told it exists. - _, err := e.orders.PayWithPointsInApp(e.ctx, stranger, order, 1000, "482913", info) - assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound) - _, err = e.orders.PayWithPointsInApp(e.ctx, owner, uuid.New(), 1000, "482913", info) - assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound) - - // The session alone is not enough: a wrong PIN takes nothing. - _, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 1000, "000000", info) - var pe *PinError - require.ErrorAs(t, err, &pe) - assert.Equal(t, PinErrInvalid, pe.Code) - assert.Equal(t, int64(100000), e.balance(owner)) - - // The owner pays part, then the rest, with the same rules as at the cashier. - payment, err := e.orders.PayWithPointsInApp(e.ctx, owner, order, 10000, "482913", info) - require.NoError(t, err) - assert.Equal(t, int64(10000), *payment.PointsUsed) - status, remaining := e.orderState(order) - assert.Equal(t, "partial", status) - assert.Equal(t, 50000.0, remaining) - - _, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50001, "482913", info) - assert.ErrorIs(t, err, ErrPointPaymentRejected, "not more than what is left") - _, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50000, "482913", info) - require.NoError(t, err) - status, _ = e.orderState(order) - assert.Equal(t, "completed", status) - assert.Equal(t, int64(40000), e.balance(owner)) - assert.Equal(t, int64(100000), e.balance(stranger)) - - var createdBy *string - require.NoError(t, e.db.Raw(`SELECT created_by_user::text FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT' LIMIT 1`, owner).Scan(&createdBy).Error) - assert.Nil(t, createdBy, "no cashier took an in-app payment") -} - -// The payment method report counts only money actually received as money in; the -// EnakPoint part is listed apart (F9). -func TestPointPayment_ReportKeepsEnakPointOutOfCashIn(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(100000) - order := e.order(customer, 87500) - _, err := e.payPoints(order, 20000, e.code(customer)) - require.NoError(t, err) - _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500}) - require.NoError(t, err) - - report, err := NewAnalyticsProcessorImpl(repository.NewAnalyticsRepositoryImpl(e.db), nil).GetPaymentMethodAnalytics(context.Background(), - &models.PaymentMethodAnalyticsRequest{OrganizationID: e.org, DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now().Add(time.Hour)}) - require.NoError(t, err) - assert.Equal(t, 67500.0, report.Summary.TotalAmount, "money in is the cash, not the order total") - assert.Equal(t, 20000.0, report.Summary.PointAmount) - assert.Equal(t, int64(20000), report.Summary.PointsUsed) - assert.Equal(t, 87500.0, report.Summary.TotalWithPoints) - require.Len(t, report.Data, 2) - for _, d := range report.Data { - assert.Equal(t, d.PaymentMethodType != "point", d.CountsAsCashIn, d.PaymentMethodName) - } -} diff --git a/internal/processor/point_payment_method_db_test.go b/internal/processor/point_payment_method_db_test.go deleted file mode 100644 index eb912ed..0000000 --- a/internal/processor/point_payment_method_db_test.go +++ /dev/null @@ -1,118 +0,0 @@ -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/models" - "apskel-pos-be/internal/repository" -) - -// Needs TEST_DATABASE_URL pointing at a migrated database; see -// internal/repository/wallet_repository_test.go. -func TestPointPaymentMethod_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, user, accepting, refusing := uuid.New(), uuid.New(), uuid.New(), uuid.New() - exec := func(q string, args ...any) error { return db.Exec(q, args...).Error } - require.NoError(t, exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'pm test', 'basic')`, org)) - require.NoError(t, exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Terima'), (?, ?, 'Tolak')`, accepting, org, refusing, org)) - t.Cleanup(func() { - db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org) - db.Exec(`DELETE FROM outlet_settings WHERE outlet_id IN ?`, []uuid.UUID{accepting, refusing}) - db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{accepting, refusing}) - db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, org) - db.Exec(`DELETE FROM organizations WHERE id = ?`, org) - }) - - // A new organization gets exactly one EnakPoint method from the trigger. - var methods []struct { - ID string - Name string - Type string - } - require.NoError(t, db.Raw(`SELECT id::text AS id, name, type FROM payment_methods WHERE organization_id = ?`, org).Scan(&methods).Error) - require.Len(t, methods, 1) - assert.Equal(t, "EnakPoint", methods[0].Name) - assert.Equal(t, "point", methods[0].Type) - pointID := uuid.MustParse(methods[0].ID) - - // The database refuses a second one. - assert.Error(t, exec(`INSERT INTO payment_methods (organization_id, name, type) VALUES (?, 'EnakPoint 2', 'point')`, org)) - - txm := repository.NewTxManager(db) - settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) - s, err := settings.Outlet(ctx, accepting) - require.NoError(t, err) - s.PointPayment.AcceptPayment = true - _, err = settings.UpdateOutlet(ctx, org, accepting, user, *s) - require.NoError(t, err) - - p := NewPaymentMethodProcessorImpl(repository.NewPaymentMethodRepositoryImpl(db), settings) - cash, err := p.CreatePaymentMethod(ctx, &models.CreatePaymentMethodRequest{OrganizationID: org, Name: "Tunai", Type: constants.PaymentMethodTypeCash, IsActive: ptr(true)}) - require.NoError(t, err) - - // The API cannot make, retype or delete an EnakPoint method. - _, err = p.CreatePaymentMethod(ctx, &models.CreatePaymentMethodRequest{OrganizationID: org, Name: "Poin Lain", Type: constants.PaymentMethodTypePoint, IsActive: ptr(true)}) - assert.ErrorIs(t, err, ErrSystemPaymentMethod) - toCash := constants.PaymentMethodTypeCash - _, err = p.UpdatePaymentMethod(ctx, pointID, &models.UpdatePaymentMethodRequest{Type: &toCash}) - assert.ErrorIs(t, err, ErrSystemPaymentMethod) - toPoint := constants.PaymentMethodTypePoint - _, err = p.UpdatePaymentMethod(ctx, cash.ID, &models.UpdatePaymentMethodRequest{Type: &toPoint}) - assert.ErrorIs(t, err, ErrSystemPaymentMethod) - assert.ErrorIs(t, p.DeletePaymentMethod(ctx, pointID), ErrSystemPaymentMethod) - - // Renaming it is fine; its type stays. - name := "Bayar pakai EnakPoint" - renamed, err := p.UpdatePaymentMethod(ctx, pointID, &models.UpdatePaymentMethodRequest{Name: &name, Type: &toPoint}) - require.NoError(t, err) - assert.Equal(t, name, renamed.Name) - - // At the cashier it shows only where the outlet accepts EnakPoint. - types := func(outlet *uuid.UUID) []constants.PaymentMethodType { - t.Helper() - list, err := p.ListPaymentMethods(ctx, &models.ListPaymentMethodsRequest{OrganizationID: &org, OutletID: outlet, Page: 1, Limit: 50}) - require.NoError(t, err) - var out []constants.PaymentMethodType - for _, m := range list.PaymentMethods { - out = append(out, m.Type) - } - assert.Equal(t, len(out), list.TotalCount, "the count matches what is listed") - return out - } - assert.ElementsMatch(t, []constants.PaymentMethodType{"cash", "point"}, types(&accepting)) - assert.ElementsMatch(t, []constants.PaymentMethodType{"cash"}, types(&refusing)) - assert.ElementsMatch(t, []constants.PaymentMethodType{"cash", "point"}, types(nil), "the dashboard, without an outlet, sees it") - - // A payment either records both points_used and point_value, or neither. - var orderID uuid.UUID - require.NoError(t, exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'K', ?, 'x', 'cashier')`, user, org, user.String()+"@t")) - orderID = uuid.New() - require.NoError(t, exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, order_number, order_type, subtotal, tax_amount, total_amount) - VALUES (?, ?, ?, ?, ?, 'dine_in', 1000, 0, 1000)`, orderID, org, accepting, user, "PM-"+orderID.String()[:8])) - t.Cleanup(func() { - db.Exec(`DELETE FROM payments WHERE order_id = ?`, orderID) - db.Exec(`DELETE FROM orders WHERE id = ?`, orderID) - db.Exec(`DELETE FROM users WHERE id = ?`, user) - }) - assert.Error(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used) VALUES (?, ?, 1000, 1000)`, orderID, pointID)) - assert.Error(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used, point_value) VALUES (?, ?, 1000, 0, 1)`, orderID, pointID)) - assert.NoError(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used, point_value) VALUES (?, ?, 1000, 1000, 1)`, orderID, pointID)) - assert.NoError(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount) VALUES (?, ?, 1000)`, orderID, cash.ID)) -} diff --git a/internal/processor/point_payment_processor.go b/internal/processor/point_payment_processor.go deleted file mode 100644 index 0e989cd..0000000 --- a/internal/processor/point_payment_processor.go +++ /dev/null @@ -1,336 +0,0 @@ -package processor - -import ( - "context" - "errors" - "fmt" - "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" -) - -// ErrPointPaymentRejected wraps every reason a payment with EnakPoint is refused: the -// order, the customer, the outlet or the amount. The message says which. -var ErrPointPaymentRejected = errors.New("EnakPoint payment refused") - -type pointPaymentSettings interface { - Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) - PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) -} - -type spendableReader interface { - SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) -} - -// PointPaymentInput is one payment of an order with EnakPoint. -type PointPaymentInput struct { - OrderID uuid.UUID - PaymentMethodID uuid.UUID - Points int64 - // The cashier taking the payment at the POS; nil when the customer pays in the app. - CashierID *uuid.UUID - // Authorize proves the customer agreed, before anything is taken: at the POS it - // redeems the payment code, in the app it checks the PIN (K8). - Authorize func(ctx context.Context, customerID uuid.UUID) error -} - -// PointPaymentResult is the payment made and where it left the order. -type PointPaymentResult struct { - Payment *entities.Payment - // True when this payment settled the order. - Completed bool - // Rupiah still to pay with another method. - Remaining float64 -} - -// pointPaymentLimits applies the formula of docs/prd-point-coin.md F9, in cents: -// -// cap = min(remaining, total × max_payment_percent / 100 − already paid with EnakPoint) -// max_points = min(balance, floor(cap / point_value)) -type pointPaymentLimits struct { - RemainingCents int64 - CapCents int64 - MaxPoints int64 -} - -func computePointPaymentLimits(total, totalPaid, paidWithPoints float64, maxPercent, pointValue, balance int64) pointPaymentLimits { - remaining := toCents(total) - toCents(totalPaid) - if remaining < 0 { - remaining = 0 - } - byPercent := toCents(total)*maxPercent/100 - toCents(paidWithPoints) - capCents := min(remaining, byPercent) - if capCents < 0 { - capCents = 0 - } - maxPoints := int64(0) - if pointValue > 0 { - maxPoints = min(balance, capCents/(pointValue*100)) - } - if maxPoints < 0 { - maxPoints = 0 - } - return pointPaymentLimits{RemainingCents: remaining, CapCents: capCents, MaxPoints: maxPoints} -} - -// PointPaymentProcessor pays orders with EnakPoint (docs/prd-point-coin.md F9). -type PointPaymentProcessor struct { - repo repository.PointPaymentRepository - settings pointPaymentSettings - spendable spendableReader - wallet *WalletProcessor - tx TxRunner - now func() time.Time -} - -func NewPointPaymentProcessor(repo repository.PointPaymentRepository, settings pointPaymentSettings, spendable spendableReader, wallet *WalletProcessor, tx TxRunner) *PointPaymentProcessor { - return &PointPaymentProcessor{repo: repo, settings: settings, spendable: spendable, wallet: wallet, tx: tx, now: time.Now} -} - -// Preview is GET /orders/:id/point-payment/preview: whether the order can be paid with -// EnakPoint, and at most how much. -func (p *PointPaymentProcessor) Preview(ctx context.Context, organizationID, orderID uuid.UUID) (*models.PointPaymentPreview, error) { - order, err := p.repo.GetOrder(ctx, orderID, false) - if err != nil { - return nil, err - } - if order.OrganizationID != organizationID { - return nil, repository.ErrPointPaymentOrderNotFound - } - preview := &models.PointPaymentPreview{OrderID: orderID, CustomerID: order.CustomerID} - settings, err := p.settings.Outlet(ctx, order.OutletID) - if err != nil { - return nil, err - } - value, err := p.settings.PointValue(ctx, order.OrganizationID) - if err != nil { - return nil, err - } - preview.PointValue = value - preview.MinPaymentPoints = settings.PointPayment.MinPaymentPoints - preview.MaxPaymentPercent = settings.PointPayment.MaxPaymentPercent - - if reason := pointPaymentOrderProblem(order, settings); reason != "" { - preview.Reason = reason - return preview, nil - } - limits, balance, err := p.limits(ctx, order, settings, value) - if err != nil { - return nil, err - } - preview.PointBalance = balance - preview.RemainingAmount = float64(limits.RemainingCents) / 100 - preview.MaxPoints = limits.MaxPoints - preview.MaxAmount = limits.MaxPoints * value - if limits.MaxPoints < settings.PointPayment.MinPaymentPoints { - preview.Reason = "the customer cannot pay the minimum of EnakPoint on this order" - return preview, nil - } - preview.Eligible = true - return preview, nil -} - -// Pay takes EnakPoint from the order's customer and records the payment. The payment -// row, the ledger PAYMENT row, the balance and the order change in one transaction -// with the order row and the wallet locked (F9 steps 1–6). -func (p *PointPaymentProcessor) Pay(ctx context.Context, in PointPaymentInput) (*PointPaymentResult, error) { - reject := func(format string, args ...any) error { - return fmt.Errorf("%w: %s", ErrPointPaymentRejected, fmt.Sprintf(format, args...)) - } - if in.Points <= 0 { - return nil, reject("the number of EnakPoint must be positive") - } - order, err := p.repo.GetOrder(ctx, in.OrderID, false) - if err != nil { - return nil, err - } - methodOrg, methodType, err := p.repo.GetPaymentMethod(ctx, in.PaymentMethodID) - if err != nil { - return nil, err - } - if methodType != string(constants.PaymentMethodTypePoint) || methodOrg != order.OrganizationID { - return nil, reject("the payment method is not this organization's EnakPoint method") - } - settings, err := p.settings.Outlet(ctx, order.OutletID) - if err != nil { - return nil, err - } - if reason := pointPaymentOrderProblem(order, settings); reason != "" { - return nil, reject("%s", reason) - } - if in.Points < settings.PointPayment.MinPaymentPoints { - return nil, reject("at least %d EnakPoint must be used", settings.PointPayment.MinPaymentPoints) - } - customerID := *order.CustomerID - - // The customer agrees before anything is taken. A code is used up here even if the - // payment then fails, and the customer shows a new one. - if in.Authorize == nil { - return nil, reject("the customer has not approved the payment") - } - if err := in.Authorize(ctx, customerID); err != nil { - return nil, err - } - - result := &PointPaymentResult{} - err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { - // Lock the order, then the wallet, and read everything again: another payment - // may have landed since the checks above. - order, err := p.repo.GetOrder(ctx, in.OrderID, true) - if err != nil { - return err - } - if reason := pointPaymentOrderProblem(order, settings); reason != "" { - return reject("%s", reason) - } - if err := p.wallet.LockWallet(ctx, customerID); err != nil { - return err - } - value, err := p.settings.PointValue(ctx, order.OrganizationID) - if err != nil { - return err - } - limits, _, err := p.limits(ctx, order, settings, value) - if err != nil { - return err - } - if in.Points > limits.MaxPoints { - return reject("at most %d EnakPoint can pay this order now", limits.MaxPoints) - } - amountCents := in.Points * value * 100 - // No change is ever given for EnakPoint (K7); the limits already keep it - // within what is left, this only guards that. - if amountCents > limits.RemainingCents { - return reject("EnakPoint cannot pay more than what is left on the order") - } - - pointsUsed := in.Points - frozenValue := float64(value) - payment := &entities.Payment{ - ID: uuid.New(), - OrderID: order.ID, - PaymentMethodID: in.PaymentMethodID, - Amount: float64(amountCents) / 100, - Status: entities.PaymentTransactionStatusCompleted, - PointsUsed: &pointsUsed, - PointValue: &frozenValue, - Metadata: entities.Metadata{"points_used": pointsUsed, "point_value": value}, - } - if err := p.repo.InsertPayment(ctx, payment); err != nil { - return err - } - - outletID := order.OutletID - if _, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ - CustomerID: customerID, - Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypePayment, - Amount: in.Points, - ReferenceType: constants.WalletRefTypePayment, - ReferenceID: payment.ID, - OutletID: &outletID, - CreatedByUser: in.CashierID, - Description: pointPaymentDescription(order, amountCents), - Metadata: entities.Metadata{"point_value": value, "amount": payment.Amount}, - IdempotencyKey: "payment:" + payment.ID.String(), - }}); err != nil { - if errors.Is(err, repository.ErrWalletInsufficientBalance) { - return reject("the customer does not have enough EnakPoint") - } - return err - } - - remainingCents := limits.RemainingCents - amountCents - completed := remainingCents == 0 - if err := p.repo.UpdateOrderAfterPayment(ctx, order.ID, float64(remainingCents)/100, completed); err != nil { - return err - } - result.Payment = payment - result.Completed = completed - result.Remaining = float64(remainingCents) / 100 - return nil - }) - if err != nil { - return nil, err - } - return result, nil -} - -func (p *PointPaymentProcessor) limits(ctx context.Context, order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings, value int64) (pointPaymentLimits, int64, error) { - totalPaid, err := p.repo.TotalPaid(ctx, order.ID) - if err != nil { - return pointPaymentLimits{}, 0, err - } - paidWithPoints, err := p.repo.PaidWithPoints(ctx, order.ID) - if err != nil { - return pointPaymentLimits{}, 0, err - } - balances, err := p.spendable.SpendableBalances(ctx, *order.CustomerID, p.now()) - if err != nil { - return pointPaymentLimits{}, 0, err - } - balance := balances[constants.WalletCurrencyPoint] - return computePointPaymentLimits(order.TotalAmount, totalPaid, paidWithPoints, settings.PointPayment.MaxPaymentPercent, value, balance), balance, nil -} - -// pointPaymentOrderProblem says why an order cannot be paid with EnakPoint at all, or -// "" when it can. -func pointPaymentOrderProblem(order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings) string { - switch { - case order.IsVoid: - return "the order is void" - case order.PaymentStatus == string(entities.PaymentStatusCompleted): - return "the order is already paid" - case !settings.PointPayment.AcceptPayment: - return "this outlet does not accept EnakPoint" - case order.CustomerID == nil || order.CustomerIsDefault == nil: - return "the order has no customer" - case *order.CustomerIsDefault: - return "a walk-in order cannot be paid with EnakPoint" - case order.CustomerIsActive == nil || !*order.CustomerIsActive: - return "the customer is not active" - } - return "" -} - -func pointPaymentDescription(order *repository.PointPaymentOrder, amountCents int64) string { - description := "Bayar #" + order.OrderNumber - if order.OutletName != "" { - description += " di " + order.OutletName - } - description += " (Rp " + formatRupiah(amountCents/100) + ")" - return truncateRunes(description, walletDescriptionLimit) -} - -// formatRupiah writes 50000 as 50.000. -func formatRupiah(n int64) string { - s := fmt.Sprintf("%d", n) - out := make([]byte, 0, len(s)+len(s)/3) - for i, c := range []byte(s) { - if i > 0 && (len(s)-i)%3 == 0 { - out = append(out, '.') - } - out = append(out, c) - } - return string(out) -} - -// PointMethodID returns the organization's EnakPoint payment method. -func (p *PointPaymentProcessor) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) { - return p.repo.PointMethodID(ctx, organizationID) -} - -// OrderOwner returns the organization and customer of an order, for checking that a -// customer pays only their own order. -func (p *PointPaymentProcessor) OrderOwner(ctx context.Context, orderID uuid.UUID) (organizationID uuid.UUID, customerID *uuid.UUID, err error) { - order, err := p.repo.GetOrder(ctx, orderID, false) - if err != nil { - return uuid.Nil, nil, err - } - return order.OrganizationID, order.CustomerID, nil -} diff --git a/internal/processor/point_payment_processor_test.go b/internal/processor/point_payment_processor_test.go deleted file mode 100644 index 4b879c3..0000000 --- a/internal/processor/point_payment_processor_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package processor - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestComputePointPaymentLimits(t *testing.T) { - for name, c := range map[string]struct { - total, paid, paidWithPoints float64 - percent, value, balance int64 - wantRemaining, wantMax int64 - }{ - // The F9 example: Rp 87.550 left, 50.000 EnakPoint, 100%, Rp 1 a point. - "PRD example": {87550, 0, 0, 100, 1, 50000, 8755000, 50000}, - "balance covers it all": {87550, 0, 0, 100, 1, 100000, 8755000, 87550}, - "part already paid": {100000, 30000, 0, 100, 1, 100000, 7000000, 70000}, - "capped by percent": {100000, 0, 0, 50, 1, 100000, 10000000, 50000}, - "percent counts EnakPoint already used": {100000, 20000, 20000, 50, 1, 100000, 8000000, 30000}, - "percent cap already used": {100000, 50000, 50000, 50, 1, 100000, 5000000, 0}, - "point worth more than Rp 1": {87550, 0, 0, 100, 100, 1000, 8755000, 875}, - "nothing left": {50000, 50000, 0, 100, 1, 100000, 0, 0}, - "overpaid": {50000, 60000, 0, 100, 1, 100000, 0, 0}, - "no balance": {50000, 0, 0, 100, 1, 0, 5000000, 0}, - "cents left over": {10000.50, 0, 0, 100, 1, 100000, 1000050, 10000}, - } { - got := computePointPaymentLimits(c.total, c.paid, c.paidWithPoints, c.percent, c.value, c.balance) - assert.Equal(t, c.wantRemaining, got.RemainingCents, name) - assert.Equal(t, c.wantMax, got.MaxPoints, name) - assert.LessOrEqual(t, got.MaxPoints*c.value*100, got.RemainingCents, "%s: never more than what is left", name) - } -} - -func TestFormatRupiah(t *testing.T) { - for n, want := range map[int64]string{0: "0", 999: "999", 1000: "1.000", 50000: "50.000", 1234567: "1.234.567"} { - assert.Equal(t, want, formatRupiah(n)) - } -} diff --git a/internal/processor/point_payment_refund.go b/internal/processor/point_payment_refund.go deleted file mode 100644 index 9b6397a..0000000 --- a/internal/processor/point_payment_refund.go +++ /dev/null @@ -1,157 +0,0 @@ -package processor - -import ( - "context" - "fmt" - - "github.com/google/uuid" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/repository" -) - -// RefundForOrder gives EnakPoint back for the order's EnakPoint payments, as far as the -// order has been voided or those payments refunded (docs/prd-point-coin.md F9, K7): -// -// - void: every EnakPoint used on the order; -// - a refunded EnakPoint payment: floor(refunded rupiah / the frozen point value), -// so a later change of the point value does not change how many come back, and a -// rupiah remainder below one EnakPoint is lost (Q13). -// -// Never more than the payment used, and only what has not come back yet, so it can be -// called again safely. Returned EnakPoint go back into lots with the expiry of the lots -// they were taken from, but at least seven days from the refund (note N4, decided). It -// returns how many came back in total. -func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid.UUID) (int64, error) { - order, err := p.repo.GetOrder(ctx, orderID, false) - if err != nil { - return 0, err - } - payments, err := p.repo.ListPointPayments(ctx, orderID) - if err != nil { - return 0, err - } - - var returned int64 - for _, payment := range payments { - if payment.LedgerID == nil || payment.CustomerID == nil { - continue - } - target := pointRefundTarget(order.IsVoid, payment) - if target == 0 { - continue - } - var n int64 - err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { - if err := p.wallet.LockWallet(ctx, *payment.CustomerID); err != nil { - return err - } - allocations, err := p.repo.PaymentAllocations(ctx, *payment.LedgerID) - if err != nil { - return err - } - refunded, err := p.repo.RefundedByOriginLot(ctx, *payment.LedgerID) - if err != nil { - return err - } - var already int64 - for _, amount := range refunded { - already += amount - } - toReturn := target - already - if toReturn <= 0 { - return nil - } - - // Fill the lots the payment took from, each up to what it gave. - var lots []WalletLotInput - left := toReturn - for _, a := range allocations { - if left == 0 { - break - } - room := a.Amount - refunded[a.LotID] - if room <= 0 { - continue - } - take := min(room, left) - left -= take - lotID := a.LotID - lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: RefundExpiry(a.ExpiresAt, p.now()), OriginLotID: &lotID}) - } - toReturn -= left - - ledgerID := *payment.LedgerID - if _, err := p.wallet.Credit(ctx, WalletCreditInput{ - WalletEntry: WalletEntry{ - CustomerID: *payment.CustomerID, - Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypePaymentRefund, - Amount: toReturn, - ReferenceType: constants.WalletRefTypePayment, - ReferenceID: payment.PaymentID, - ReversesTransactionID: &ledgerID, - OutletID: payment.OutletID, - Description: pointRefundDescription(order), - Metadata: entities.Metadata{"point_value": payment.PointValue, "target": target, "void": order.IsVoid}, - IdempotencyKey: fmt.Sprintf("payment-refund:%s:%d", payment.PaymentID, target), - }, - Lots: lots, - }); err != nil { - return err - } - n = toReturn - return nil - }) - if err != nil { - return returned, fmt.Errorf("refunding EnakPoint payment %s: %w", payment.PaymentID, err) - } - returned += n - } - return returned, nil -} - -// EnsureOrderRefundAllowed refuses an order-level refund that would hand back, in cash -// or another method, what was paid with EnakPoint (K7). The EnakPoint part is refunded -// through its own payment, and comes back as EnakPoint. -func (p *PointPaymentProcessor) EnsureOrderRefundAllowed(ctx context.Context, orderID uuid.UUID, amount float64) error { - refundable, err := p.repo.RefundableByOtherMethods(ctx, orderID) - if err != nil { - return err - } - paidWithPoints, err := p.repo.PaidWithPoints(ctx, orderID) - if err != nil { - return err - } - if paidWithPoints == 0 { - return nil - } - if toCents(amount) > toCents(refundable) { - return fmt.Errorf("%w: at most Rp %s can be refunded this way; the part paid with EnakPoint is refunded through its EnakPoint payment and returns as EnakPoint", - ErrPointPaymentRejected, formatRupiah(toCents(refundable)/100)) - } - return nil -} - -func pointRefundTarget(orderVoid bool, payment repository.PointPaymentRow) int64 { - if orderVoid { - return payment.PointsUsed - } - if payment.Status != string(entities.PaymentTransactionStatusRefunded) { - return 0 - } - valueCents := toCents(payment.PointValue) - if valueCents <= 0 { - return 0 - } - return min(payment.PointsUsed, toCents(payment.RefundAmount)/valueCents) -} - -func pointRefundDescription(order *repository.PointPaymentOrder) string { - description := "Pengembalian #" + order.OrderNumber - if order.OutletName != "" { - description += " di " + order.OutletName - } - return truncateRunes(description, walletDescriptionLimit) -} diff --git a/internal/processor/point_refund_db_test.go b/internal/processor/point_refund_db_test.go deleted file mode 100644 index 55a5e59..0000000 --- a/internal/processor/point_refund_db_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package processor - -import ( - "context" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -func (e *pointPaymentEnv) sum(query string, args ...any) int64 { - e.t.Helper() - var n int64 - require.NoError(e.t, e.db.Raw(query, args...).Scan(&n).Error) - return n -} - -func (e *pointPaymentEnv) returned(order uuid.UUID) int64 { - return e.sum(`SELECT COALESCE(SUM(t.amount), 0) FROM wallet_transactions t - JOIN payments p ON p.id = t.reference_id - WHERE p.order_id = ? AND t.type = 'PAYMENT_REFUND'`, order) -} - -func (e *pointPaymentEnv) assertReconciled(customers ...uuid.UUID) { - e.t.Helper() - found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000) - require.NoError(e.t, err) - for _, d := range found { - for _, c := range customers { - assert.NotEqual(e.t, c, d.CustomerID, d.Check) - } - } -} - -func TestPointRefund_VoidReturnsEverythingToItsExpiry(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(0) - expires := time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second) - require.NoError(t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error { - _, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{ - WalletEntry: WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, - Amount: 40000, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}, - Lots: []WalletLotInput{{Amount: 40000, ExpiresAt: &expires}}, - }) - return err - })) - - // Part of the order paid with EnakPoint, then the order is voided. - order := e.order(customer, 50000) - _, err := e.payPoints(order, 30000, e.code(customer)) - require.NoError(t, err) - assert.Equal(t, int64(10000), e.balance(customer)) - require.NoError(t, e.orders.VoidOrder(e.ctx, &models.VoidOrderRequest{OrderID: order, Type: "ALL", Reason: "batal"}, e.cashier)) - - assert.Equal(t, int64(30000), e.returned(order)) - assert.Equal(t, int64(40000), e.balance(customer)) - var expiry time.Time - require.NoError(t, e.db.Raw(`SELECT l.expires_at FROM wallet_lots l JOIN wallet_transactions t ON t.id = l.source_transaction_id - WHERE t.customer_id = ? AND t.type = 'PAYMENT_REFUND'`, customer).Scan(&expiry).Error) - assert.WithinDuration(t, expires, expiry, time.Second, "returned EnakPoint keep the expiry they had") - - // Calling it again returns nothing more. - e.orders.onOrderRefunded(e.ctx, order) - assert.Equal(t, int64(30000), e.returned(order)) - e.assertReconciled(customer) -} - -func TestPointRefund_PartialRefundFloors(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(100000) - order := e.order(customer, 50000) - payment, err := e.payPoints(order, 50000, e.code(customer)) - require.NoError(t, err) - - // Rp 12.345,67 back at Rp 1 a point: 12.345 EnakPoint; the 67 sen are lost (Q13). - require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 12345.67, "sebagian", e.cashier)) - assert.Equal(t, int64(12345), e.returned(order)) - assert.Equal(t, int64(50000+12345), e.balance(customer)) - e.assertReconciled(customer) -} - -func TestPointRefund_UsesTheFrozenValue(t *testing.T) { - e := newPointPaymentEnv(t) - settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db)) - setValue := func(v int64) { - s, err := settings.Organization(context.Background(), e.org) - require.NoError(t, err) - s.PointValue = v - _, _, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s) - require.NoError(t, err) - } - setValue(100) - - customer := e.customerWith(1000) - order := e.order(customer, 50000) - payment, err := e.payPoints(order, 500, e.code(customer)) - require.NoError(t, err) - assert.Equal(t, 50000.0, payment.Amount, "500 × Rp 100") - - // The value changes before the refund; the customer still gets back what they used. - setValue(250) - require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 50000, "semua", e.cashier)) - assert.Equal(t, int64(500), e.returned(order), "50.000 / the frozen Rp 100, not the new Rp 250") - assert.Equal(t, int64(1000), e.balance(customer)) -} - -func TestPointRefund_NoCashForTheEnakPointPart(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(100000) - order := e.order(customer, 50000) - _, err := e.payPoints(order, 20000, e.code(customer)) - require.NoError(t, err) - _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 30000}) - require.NoError(t, err) - - amount := 40000.0 - err = e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier) - assert.ErrorIs(t, err, ErrPointPaymentRejected, "Rp 40.000 in cash would include EnakPoint") - var refunded float64 - require.NoError(t, e.db.Raw(`SELECT refund_amount FROM orders WHERE id = ?`, order).Scan(&refunded).Error) - assert.Zero(t, refunded, "nothing was written") - - amount = 30000 - require.NoError(t, e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier), "the cash part can be refunded") - assert.Zero(t, e.returned(order), "and no EnakPoint came back for it") -} - -// Giving back the EnakPoint part does not take earning back: that part never earned. -func TestPointRefund_DoesNotReverseEarning(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(100000) - order := e.order(customer, 87500) - pointPayment, err := e.payPoints(order, 20000, e.code(customer)) - require.NoError(t, err) - _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500}) - require.NoError(t, err) - earned := e.sum(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order) - require.Equal(t, int64(675), earned) - - require.NoError(t, e.orders.RefundPayment(e.ctx, pointPayment.ID, 20000, "kembali", e.cashier)) - assert.Equal(t, int64(20000), e.returned(order)) - reversed := e.sum(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL'`, order) - assert.Zero(t, reversed, "the EnakPoint part never earned, so giving it back takes nothing") - e.assertReconciled(customer) -} diff --git a/internal/processor/wallet_admin_processor.go b/internal/processor/wallet_admin_processor.go deleted file mode 100644 index 3909a35..0000000 --- a/internal/processor/wallet_admin_processor.go +++ /dev/null @@ -1,223 +0,0 @@ -package processor - -import ( - "context" - "fmt" - "strings" - "time" - "unicode/utf8" - - "github.com/google/uuid" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// walletDescriptionLimit is the size of wallet_transactions.description. -const walletDescriptionLimit = 255 - -// WalletAdminProcessor serves the dashboard's view of one customer's wallet and the -// manual adjustments an admin makes to it (docs/prd-point-coin.md F7). -type WalletAdminProcessor struct { - admin repository.WalletAdminRepository - query repository.WalletQueryRepository - wallet *WalletProcessor - settings organizationSettingsReader - tx TxRunner - now func() time.Time -} - -func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, settings organizationSettingsReader, tx TxRunner) *WalletAdminProcessor { - return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, settings: settings, tx: tx, now: time.Now} -} - -// GetCustomerWallet returns the customer's balances, lots and a page of history. -// A customer outside the organization is reported as not found. -func (p *WalletAdminProcessor) GetCustomerWallet(ctx context.Context, organizationID, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) (*models.AdminCustomerWallet, error) { - filter, page, err := walletTransactionFilter(customerID, query) - if err != nil { - return nil, err - } - customer, err := p.admin.GetCustomer(ctx, organizationID, customerID) - if err != nil { - return nil, err - } - now := p.now() - - result := &models.AdminCustomerWallet{ - Customer: models.AdminWalletCustomer{ID: customer.ID, Name: customer.Name, Phone: customer.Phone}, - Lots: []models.AdminWalletLot{}, - } - wallet, err := p.admin.GetWallet(ctx, customerID) - if err != nil { - return nil, err - } - if wallet != nil { - result.PointBalance = wallet.PointBalance - result.CoinBalance = wallet.CoinBalance - } - spendable, err := p.query.SpendableBalances(ctx, customerID, now) - if err != nil { - return nil, err - } - result.SpendablePointBalance = spendable[constants.WalletCurrencyPoint] - result.SpendableCoinBalance = spendable[constants.WalletCurrencyCoin] - - lots, err := p.admin.ListLotsWithBalance(ctx, customerID) - if err != nil { - return nil, err - } - for _, lot := range lots { - result.Lots = append(result.Lots, adminWalletLot(lot, now)) - } - - rows, total, err := p.query.ListTransactions(ctx, filter) - if err != nil { - return nil, err - } - transactions, err := p.adminTransactions(ctx, rows) - if err != nil { - return nil, err - } - result.Transactions = models.PaginatedResponse[models.AdminWalletTransaction]{ - Data: transactions, - Pagination: models.Pagination{ - Page: page, - Limit: filter.Limit, - Total: total, - TotalPages: int((total + int64(filter.Limit) - 1) / int64(filter.Limit)), - }, - } - return result, nil -} - -// Adjust adds to or takes from a customer's balance by hand. It is recorded as an -// ADJUSTMENT pointing at the admin, with the reason. Taking more than the customer -// can spend is refused with repository.ErrWalletInsufficientBalance. -func (p *WalletAdminProcessor) Adjust(ctx context.Context, organizationID, adminID, customerID uuid.UUID, in models.WalletAdjustment) (*models.AdminWalletAdjustmentResult, error) { - reason := strings.TrimSpace(in.Reason) - if adminID == uuid.Nil { - return nil, fmt.Errorf("%w: the admin making the adjustment is unknown", ErrWalletInvalidEntry) - } - if in.Amount == 0 { - return nil, fmt.Errorf("%w: amount must not be zero", ErrWalletInvalidEntry) - } - - entry := WalletEntry{ - CustomerID: customerID, - Currency: in.Currency, - Type: constants.WalletTxTypeAdjustment, - Amount: abs(in.Amount), - ReferenceType: constants.WalletRefTypeUser, - ReferenceID: adminID, - CreatedByUser: &adminID, - Reason: &reason, - Description: truncateRunes("Koreksi oleh admin: "+reason, walletDescriptionLimit), - } - if in.IdempotencyKey != "" { - // Scoped to the customer so a key chosen by the dashboard cannot collide with - // the keys other flows use. - entry.IdempotencyKey = fmt.Sprintf("adjust:%s:%s", customerID, in.IdempotencyKey) - } - - var res *WalletResult - err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { - if _, err := p.admin.GetCustomer(ctx, organizationID, customerID); err != nil { - return err - } - if in.Amount < 0 { - var err error - res, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: entry}) - return err - } - // An adjustment that adds is a new balance, and expires like one (F7, F12). - settings, err := p.settings.Organization(ctx, organizationID) - if err != nil { - return err - } - res, err = p.wallet.Credit(ctx, WalletCreditInput{ - WalletEntry: entry, - Lots: []WalletLotInput{{Amount: entry.Amount, ExpiresAt: ComputeExpiry(ExpirySettings(settings, in.Currency), p.now())}}, - }) - return err - }) - if err != nil { - return nil, err - } - - transactions, err := p.adminTransactions(ctx, []entities.WalletTransaction{*res.Transaction}) - if err != nil { - return nil, err - } - spendable, err := p.query.SpendableBalances(ctx, customerID, p.now()) - if err != nil { - return nil, err - } - return &models.AdminWalletAdjustmentResult{ - Transaction: transactions[0], - SpendablePointBalance: spendable[constants.WalletCurrencyPoint], - SpendableCoinBalance: spendable[constants.WalletCurrencyCoin], - Replayed: res.Replayed, - }, nil -} - -// adminTransactions adds, to what the customer sees, the names behind each row. -func (p *WalletAdminProcessor) adminTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.AdminWalletTransaction, error) { - base, err := buildWalletTransactions(ctx, p.query, rows) - if err != nil { - return nil, err - } - - var customerIDs, userIDs, outletIDs []uuid.UUID - for _, row := range rows { - if row.CounterpartyCustomerID != nil { - customerIDs = append(customerIDs, *row.CounterpartyCustomerID) - } - if row.CreatedByUser != nil { - userIDs = append(userIDs, *row.CreatedByUser) - } - if row.OutletID != nil { - outletIDs = append(outletIDs, *row.OutletID) - } - } - customers, err := p.admin.CustomerNames(ctx, customerIDs) - if err != nil { - return nil, err - } - users, err := p.admin.UserNames(ctx, userIDs) - if err != nil { - return nil, err - } - outlets, err := p.admin.OutletNames(ctx, outletIDs) - if err != nil { - return nil, err - } - named := func(id *uuid.UUID, names map[uuid.UUID]string) *models.AdminWalletNamedRef { - if id == nil { - return nil - } - return &models.AdminWalletNamedRef{ID: *id, Name: names[*id]} - } - - out := make([]models.AdminWalletTransaction, 0, len(rows)) - for i, row := range rows { - out = append(out, models.AdminWalletTransaction{ - CustomerWalletTransaction: base[i], - Counterparty: named(row.CounterpartyCustomerID, customers), - CreatedBy: named(row.CreatedByUser, users), - Outlet: named(row.OutletID, outlets), - Reason: row.Reason, - Metadata: row.Metadata, - }) - } - return out, nil -} - -func truncateRunes(s string, limit int) string { - if utf8.RuneCountInString(s) <= limit { - return s - } - return string([]rune(s)[:limit]) -} diff --git a/internal/processor/wallet_exchange_processor.go b/internal/processor/wallet_exchange_processor.go deleted file mode 100644 index 71552a1..0000000 --- a/internal/processor/wallet_exchange_processor.go +++ /dev/null @@ -1,289 +0,0 @@ -package processor - -import ( - "context" - "errors" - "fmt" - "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" -) - -// ErrWalletMoveRejected wraps every reason an exchange or a transfer is refused on -// the customer's side: the amount, the limits, the recipient or the balance. The -// message says which. -var ErrWalletMoveRejected = errors.New("wallet move refused") - -// walletMoveKeyLimit keeps a client's Idempotency-Key short enough to fit, with the -// prefix that scopes it to the customer, in wallet_transactions.idempotency_key. -const walletMoveKeyLimit = 50 - -type organizationSettingsReader interface { - Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) -} - -// WalletExchangeProcessor exchanges EnakCoin into EnakPoint (docs/prd-point-coin.md -// F4, K3). It is one way only; nothing turns EnakPoint back into EnakCoin. -type WalletExchangeProcessor struct { - customers repository.WalletMoveRepository - settings organizationSettingsReader - spendable spendableReader - pins pinVerifier - wallet *WalletProcessor - tx TxRunner - now func() time.Time -} - -func NewWalletExchangeProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner) *WalletExchangeProcessor { - return &WalletExchangeProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, now: time.Now} -} - -// Preview is GET /customer/wallet/exchange/preview: the organization's rate and what -// exchanging coins would give, so the app can show it before asking for the PIN. -func (p *WalletExchangeProcessor) Preview(ctx context.Context, customerID uuid.UUID, coins int64) (*models.WalletExchangePreview, error) { - customer, err := p.customers.GetCustomer(ctx, customerID) - if err != nil { - return nil, err - } - settings, err := p.settings.Organization(ctx, customer.OrganizationID) - if err != nil { - return nil, err - } - balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now()) - if err != nil { - return nil, err - } - rate := settings.Exchange - preview := &models.WalletExchangePreview{ - CoinAmount: rate.CoinAmount, - PointAmount: rate.PointAmount, - CoinBalance: balances[constants.WalletCurrencyCoin], - Coins: coins, - } - if reason := exchangeProblem(customer, rate, coins); reason != "" { - preview.Reason = reason - return preview, nil - } - preview.Points = exchangePoints(coins, rate) - if coins > preview.CoinBalance { - preview.Reason = "not enough EnakCoin" - return preview, nil - } - preview.Valid = true - return preview, nil -} - -// Exchange takes coins EnakCoin and gives the EnakPoint they are worth at the -// organization's rate, in one transaction. The two ledger rows share a group and -// point at each other, and both freeze the rate. Each EnakPoint lot keeps the expiry -// of the EnakCoin lot it came from, so exchanging cannot extend a balance's life -// (K9). The PIN approves it (K8). -// -// idempotencyKey is the client's Idempotency-Key: a retry with the same key returns -// the first exchange, at the rate it was made, without moving anything again. -func (p *WalletExchangeProcessor) Exchange(ctx context.Context, customerID uuid.UUID, coins int64, pin, idempotencyKey string, info models.CustomerPinRequestInfo) (*models.WalletExchangeResult, error) { - key, err := walletMoveKey(idempotencyKey) - if err != nil { - return nil, err - } - customer, err := p.customers.GetCustomer(ctx, customerID) - if err != nil { - return nil, err - } - settings, err := p.settings.Organization(ctx, customer.OrganizationID) - if err != nil { - return nil, err - } - // Refuse a malformed request before the PIN is checked, so a typo in the amount - // costs the customer no PIN attempt. - if reason := exchangeProblem(customer, settings.Exchange, coins); reason != "" { - return nil, fmt.Errorf("%w: %s", ErrWalletMoveRejected, reason) - } - if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionExchange, info); err != nil { - return nil, err - } - - outKey := fmt.Sprintf("exchange:%s:%s:out", customerID, key) - inKey := fmt.Sprintf("exchange:%s:%s:in", customerID, key) - result := &models.WalletExchangeResult{Coins: coins} - err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { - if err := p.wallet.LockWallet(ctx, customerID); err != nil { - return err - } - rate := settings.Exchange - groupID, outID, inID := uuid.New(), uuid.New(), uuid.New() - previous, err := p.wallet.FindTransaction(ctx, outKey) - if err != nil { - return err - } - if previous != nil && previous.GroupID != nil { - // A retry: repeat it with the ids and the rate the first attempt froze, so - // both rows replay even if the rate has changed since. - outID, inID, groupID = previous.ID, previous.ReferenceID, *previous.GroupID - rate = frozenExchangeRate(previous.Metadata, rate) - } - points := exchangePoints(coins, rate) - metadata := entities.Metadata{ - "coins": coins, - "points": points, - "coin_amount": rate.CoinAmount, - "point_amount": rate.PointAmount, - } - - out, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ - TransactionID: outID, - CustomerID: customerID, - Currency: constants.WalletCurrencyCoin, - Type: constants.WalletTxTypeExchangeOut, - Amount: coins, - ReferenceType: constants.WalletRefTypeWalletTx, - ReferenceID: inID, - GroupID: &groupID, - Description: fmt.Sprintf("Tukar %d EnakCoin ke EnakPoint", coins), - Metadata: metadata, - IdempotencyKey: outKey, - }}) - if errors.Is(err, repository.ErrWalletInsufficientBalance) { - return fmt.Errorf("%w: not enough EnakCoin", ErrWalletMoveRejected) - } - if err != nil { - return err - } - in, err := p.wallet.Credit(ctx, WalletCreditInput{ - WalletEntry: WalletEntry{ - TransactionID: inID, - CustomerID: customerID, - Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypeExchangeIn, - Amount: points, - ReferenceType: constants.WalletRefTypeWalletTx, - ReferenceID: outID, - GroupID: &groupID, - Description: fmt.Sprintf("Dari tukar %d EnakCoin", coins), - Metadata: metadata, - IdempotencyKey: inKey, - }, - Lots: exchangeLots(out.Allocations, rate, ComputeExpiry(settings.PointExpiry, p.now())), - }) - if err != nil { - return err - } - - result.GroupID = groupID - result.Points = points - result.CoinAmount = rate.CoinAmount - result.PointAmount = rate.PointAmount - result.Lots = movedLots(in.Lots) - result.Replayed = out.Replayed - return nil - }) - if err != nil { - return nil, err - } - - balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now()) - if err != nil { - return nil, err - } - result.CoinBalance = balances[constants.WalletCurrencyCoin] - result.PointBalance = balances[constants.WalletCurrencyPoint] - return result, nil -} - -// exchangeProblem says why coins cannot be exchanged, or "" when they can as far as -// the request goes. The balance is checked under the wallet lock. -func exchangeProblem(customer *repository.WalletMoveCustomer, rate models.LoyaltyExchangeSettings, coins int64) string { - switch { - case !customer.IsActive: - return "the customer is not active" - case coins <= 0: - return "the number of EnakCoin must be positive" - case rate.CoinAmount <= 0 || rate.PointAmount <= 0: - return "exchange is not available" - case coins%rate.CoinAmount != 0: - // Otherwise part of the EnakCoin would be lost to rounding (F4). - return fmt.Sprintf("EnakCoin are exchanged in multiples of %d", rate.CoinAmount) - } - return "" -} - -// exchangePoints is (coins / coin_amount) × point_amount, for coins that are a -// multiple of coin_amount. -func exchangePoints(coins int64, rate models.LoyaltyExchangeSettings) int64 { - return coins / rate.CoinAmount * rate.PointAmount -} - -// exchangeLots splits the EnakPoint of an exchange over the EnakCoin lots it took, -// so each part keeps the expiry of its lot and points back at it (K9). The share of -// a lot is the difference of floor(coins so far × point_amount / coin_amount) before -// and after it, which adds up exactly because the total is a multiple of -// coin_amount. A lot too small to earn a whole EnakPoint on its own gives none. -// -// Each part expires at the sooner of its EnakCoin lot's expiry and pointExpiry, when an -// EnakPoint received now would expire (F4); nil means never. -func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings, pointExpiry *time.Time) []WalletLotInput { - var lots []WalletLotInput - var coinsSoFar int64 - for _, a := range allocations { - before := coinsSoFar * rate.PointAmount / rate.CoinAmount - coinsSoFar += a.Amount - points := coinsSoFar*rate.PointAmount/rate.CoinAmount - before - if points == 0 { - continue - } - lotID := a.LotID - lots = append(lots, WalletLotInput{Amount: points, ExpiresAt: EarlierExpiry(a.ExpiresAt, pointExpiry), OriginLotID: &lotID}) - } - return lots -} - -// frozenExchangeRate reads the rate an exchange was made at from its ledger row. -func frozenExchangeRate(metadata entities.Metadata, fallback models.LoyaltyExchangeSettings) models.LoyaltyExchangeSettings { - coinAmount, ok1 := metadataInt(metadata, "coin_amount") - pointAmount, ok2 := metadataInt(metadata, "point_amount") - if !ok1 || !ok2 || coinAmount <= 0 || pointAmount <= 0 { - return fallback - } - return models.LoyaltyExchangeSettings{CoinAmount: coinAmount, PointAmount: pointAmount} -} - -// metadataInt reads a whole number from metadata that may have been through JSONB, -// which gives numbers back as float64. -func metadataInt(metadata entities.Metadata, key string) (int64, bool) { - switch v := metadata[key].(type) { - case float64: - return int64(v), true - case int64: - return v, true - case int: - return int64(v), true - } - return 0, false -} - -func movedLots(lots []entities.WalletLot) []models.WalletMovedLot { - out := make([]models.WalletMovedLot, 0, len(lots)) - for _, lot := range lots { - out = append(out, models.WalletMovedLot{Amount: lot.OriginalAmount, ExpiresAt: lot.ExpiresAt}) - } - return out -} - -// walletMoveKey checks the client's Idempotency-Key, which exchanges and transfers -// require (F4, F5). -func walletMoveKey(key string) (string, error) { - key = strings.TrimSpace(key) - if key == "" { - return "", fmt.Errorf("%w: the Idempotency-Key header is required", ErrWalletMoveRejected) - } - if len(key) > walletMoveKeyLimit { - return "", fmt.Errorf("%w: the Idempotency-Key header must be at most %d characters", ErrWalletMoveRejected, walletMoveKeyLimit) - } - return key, nil -} diff --git a/internal/processor/wallet_exchange_processor_test.go b/internal/processor/wallet_exchange_processor_test.go deleted file mode 100644 index a6d64c5..0000000 --- a/internal/processor/wallet_exchange_processor_test.go +++ /dev/null @@ -1,354 +0,0 @@ -package processor - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// walletMoveEnv runs exchanges and transfers on the in-memory wallet, so every test -// also checks the §7.5 invariants when it ends. -type walletMoveEnv struct { - *walletTestEnv - customers *walletMoveRepoFake - settings *models.OrganizationLoyaltySettings - pins *movePinFake -} - -func newWalletMoveEnv(t *testing.T) *walletMoveEnv { - e := &walletMoveEnv{ - walletTestEnv: newWalletTestEnv(t), - customers: &walletMoveRepoFake{byID: map[uuid.UUID]*repository.WalletMoveCustomer{}}, - settings: &models.OrganizationLoyaltySettings{ - PointValue: 1, - Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}, - Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1}, - }, - pins: &movePinFake{good: "482913"}, - } - e.customers.ledger = e.repo - // Ledger rows are stamped from now on, so "today" is the day of e.now. - e.repo.clock = e.now - return e -} - -// member adds an active customer of the organization. -func (e *walletMoveEnv) member(name, phone string) uuid.UUID { - id := e.customer() - e.customers.byID[id] = &repository.WalletMoveCustomer{ - ID: id, OrganizationID: e.org, Name: name, PhoneNumber: &phone, IsActive: true, - } - return id -} - -func (e *walletMoveEnv) exchanges() *WalletExchangeProcessor { - p := NewWalletExchangeProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}) - p.now = func() time.Time { return e.now } - return p -} - -func (e *walletMoveEnv) transfers(notifier customerNotifier) *WalletTransferProcessor { - p := NewWalletTransferProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}, notifier) - p.now = func() time.Time { return e.now } - return p -} - -func (e *walletMoveEnv) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) { - s := *e.settings - return &s, nil -} - -// SpendableBalances sums the unexpired lots, as the real query does. -func (e *walletMoveEnv) SpendableBalances(_ context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) { - out := map[string]int64{} - for _, lot := range e.repo.lots { - if lot.CustomerID == customerID && (lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf)) { - out[lot.Currency] += lot.RemainingAmount - } - } - return out, nil -} - -// earnCoins gives a customer an EnakCoin lot. -func (e *walletMoveEnv) earnCoins(t *testing.T, customerID uuid.UUID, amount int64, expiresAt *time.Time) *WalletResult { - t.Helper() - in := earn(customerID, amount, expiresAt) - in.Currency = constants.WalletCurrencyCoin - return e.credit(t, in) -} - -func (e *walletMoveEnv) coinBalance(t *testing.T, customerID uuid.UUID) int64 { - t.Helper() - w, err := e.repo.GetWallet(e.ctx, customerID) - require.NoError(t, err) - return w.CoinBalance -} - -type walletMoveRepoFake struct { - byID map[uuid.UUID]*repository.WalletMoveCustomer - ledger *walletRepoFake -} - -func (f *walletMoveRepoFake) GetCustomer(_ context.Context, id uuid.UUID) (*repository.WalletMoveCustomer, error) { - c, ok := f.byID[id] - if !ok { - return nil, repository.ErrWalletNotFound - } - copied := *c - return &copied, nil -} - -func (f *walletMoveRepoFake) FindCustomerByPhone(ctx context.Context, phone string) (*repository.WalletMoveCustomer, error) { - for id, c := range f.byID { - if c.PhoneNumber != nil && *c.PhoneNumber == phone { - return f.GetCustomer(ctx, id) - } - } - return nil, repository.ErrWalletNotFound -} - -func (f *walletMoveRepoFake) TransferredOutSince(_ context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) { - var total int64 - for _, tx := range f.ledger.transactions { - if tx.CustomerID == customerID && tx.Currency == currency && tx.Type == constants.WalletTxTypeTransferOut && !tx.CreatedAt.Before(since) { - total -= tx.Amount - } - } - return total, nil -} - -// movePinFake accepts one PIN and records the actions it was asked to approve. -type movePinFake struct { - good string - err error - actions []PinAction -} - -func (f *movePinFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error { - f.actions = append(f.actions, action) - if f.err != nil { - return f.err - } - if pin != f.good { - return &PinError{Code: PinErrInvalid, RemainingAttempts: 4} - } - return nil -} - -func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) { - e := newWalletMoveEnv(t) - c := e.member("Budi Santoso", "081234561234") - e.earnCoins(t, c, 50, nil) - - res, err := e.exchanges().Exchange(e.ctx, c, 50, "482913", "key-1", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - - assert.Equal(t, int64(50), res.Points) - assert.Equal(t, int64(0), res.CoinBalance) - assert.Equal(t, int64(50), res.PointBalance) - assert.Equal(t, []PinAction{PinActionExchange}, e.pins.actions) - - out, in := e.repo.transactions[1], e.repo.transactions[2] - assert.Equal(t, constants.WalletTxTypeExchangeOut, out.Type) - assert.Equal(t, constants.WalletCurrencyCoin, out.Currency) - assert.Equal(t, int64(-50), out.Amount) - assert.Equal(t, constants.WalletTxTypeExchangeIn, in.Type) - assert.Equal(t, constants.WalletCurrencyPoint, in.Currency) - assert.Equal(t, int64(50), in.Amount) - // The pair shares a group and each row points at the other (§8.1). - assert.Equal(t, *out.GroupID, *in.GroupID) - assert.Equal(t, res.GroupID, *out.GroupID) - assert.Equal(t, in.ID, out.ReferenceID) - assert.Equal(t, out.ID, in.ReferenceID) - assert.Equal(t, "Tukar 50 EnakCoin ke EnakPoint", out.Description) - assert.Equal(t, "Dari tukar 50 EnakCoin", in.Description) - // Both rows freeze the rate. - for _, row := range []*entities.WalletTransaction{out, in} { - assert.Equal(t, int64(1), row.Metadata["coin_amount"]) - assert.Equal(t, int64(1), row.Metadata["point_amount"]) - } -} - -func TestWalletExchange_TenCoinsForThreePoints(t *testing.T) { - e := newWalletMoveEnv(t) - e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} - c := e.member("Budi", "081234561234") - e.earnCoins(t, c, 35, nil) - - preview, err := e.exchanges().Preview(e.ctx, c, 30) - require.NoError(t, err) - assert.True(t, preview.Valid) - assert.Equal(t, int64(9), preview.Points) - assert.Equal(t, int64(35), preview.CoinBalance) - - res, err := e.exchanges().Exchange(e.ctx, c, 30, "482913", "key-1", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - assert.Equal(t, int64(9), res.Points) - assert.Equal(t, int64(5), res.CoinBalance) - assert.Equal(t, int64(9), res.PointBalance) -} - -func TestWalletExchange_RefusesAmountsThatAreNotAMultiple(t *testing.T) { - e := newWalletMoveEnv(t) - e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} - c := e.member("Budi", "081234561234") - e.earnCoins(t, c, 50, nil) - - preview, err := e.exchanges().Preview(e.ctx, c, 25) - require.NoError(t, err) - assert.False(t, preview.Valid) - assert.Contains(t, preview.Reason, "multiples of 10") - - _, err = e.exchanges().Exchange(e.ctx, c, 25, "482913", "key-1", models.CustomerPinRequestInfo{}) - assert.ErrorIs(t, err, ErrWalletMoveRejected) - // Refused before the PIN, so a typo costs no attempt. - assert.Empty(t, e.pins.actions) - assert.Equal(t, int64(50), e.coinBalance(t, c)) - - _, err = e.exchanges().Exchange(e.ctx, c, 0, "482913", "key-2", models.CustomerPinRequestInfo{}) - assert.ErrorIs(t, err, ErrWalletMoveRejected) -} - -func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) { - e := newWalletMoveEnv(t) - e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} - c := e.member("Budi", "081234561234") - soon, later := e.at(24*time.Hour), e.at(48*time.Hour) - first := e.earnCoins(t, c, 15, soon) - second := e.earnCoins(t, c, 15, later) - third := e.earnCoins(t, c, 10, nil) - - // 40 EnakCoin take 15 from the lot expiring soonest, 15 from the next and 10 from - // the one that never expires, giving 12 EnakPoint split 4 + 5 + 3. - res, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - require.Len(t, res.Lots, 3) - assert.Equal(t, models.WalletMovedLot{Amount: 4, ExpiresAt: soon}, res.Lots[0]) - assert.Equal(t, models.WalletMovedLot{Amount: 5, ExpiresAt: later}, res.Lots[1]) - assert.Equal(t, models.WalletMovedLot{Amount: 3}, res.Lots[2]) - - origins := map[uuid.UUID]*time.Time{} - for _, lot := range e.repo.lots { - if lot.Currency == constants.WalletCurrencyPoint { - require.NotNil(t, lot.OriginLotID) - origins[*lot.OriginLotID] = lot.ExpiresAt - } - } - assert.Equal(t, map[uuid.UUID]*time.Time{ - first.Lots[0].ID: soon, second.Lots[0].ID: later, third.Lots[0].ID: nil, - }, origins) - for _, lot := range e.repo.lots { - if lot.Currency != constants.WalletCurrencyPoint || lot.OriginLotID == nil { - continue - } - for _, coinLot := range e.repo.lots { - if coinLot.ID == *lot.OriginLotID && coinLot.ExpiresAt != nil { - require.NotNil(t, lot.ExpiresAt, "a lot that expires cannot become one that does not") - assert.False(t, lot.ExpiresAt.After(*coinLot.ExpiresAt)) - } - } - } -} - -func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) { - e := newWalletMoveEnv(t) - e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1} - c := e.member("Budi", "081234561234") - e.earnCoins(t, c, 5, e.at(time.Hour)) - e.earnCoins(t, c, 5, nil) - - // Neither lot is worth a whole EnakPoint alone; the one that completes the 10 - // carries it. - res, err := e.exchanges().Exchange(e.ctx, c, 10, "482913", "key-1", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - assert.Equal(t, []models.WalletMovedLot{{Amount: 1}}, res.Lots) -} - -func TestWalletExchange_NotEnoughCoins(t *testing.T) { - e := newWalletMoveEnv(t) - c := e.member("Budi", "081234561234") - e.earnCoins(t, c, 5, nil) - - preview, err := e.exchanges().Preview(e.ctx, c, 6) - require.NoError(t, err) - assert.False(t, preview.Valid) - - _, err = e.exchanges().Exchange(e.ctx, c, 6, "482913", "key-1", models.CustomerPinRequestInfo{}) - assert.ErrorIs(t, err, ErrWalletMoveRejected) - assert.Equal(t, int64(5), e.coinBalance(t, c)) -} - -func TestWalletExchange_WrongPinMovesNothing(t *testing.T) { - e := newWalletMoveEnv(t) - c := e.member("Budi", "081234561234") - e.earnCoins(t, c, 5, nil) - - _, err := e.exchanges().Exchange(e.ctx, c, 5, "000000", "key-1", models.CustomerPinRequestInfo{}) - var pinErr *PinError - require.True(t, errors.As(err, &pinErr)) - assert.Equal(t, PinErrInvalid, pinErr.Code) - assert.Equal(t, int64(5), e.coinBalance(t, c)) -} - -func TestWalletExchange_RetryReturnsTheFirstExchangeAtItsRate(t *testing.T) { - e := newWalletMoveEnv(t) - c := e.member("Budi", "081234561234") - e.earnCoins(t, c, 100, nil) - - first, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - // The rate changes before the retry arrives; the retry still gets the first result. - e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 2} - again, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - - assert.True(t, again.Replayed) - assert.Equal(t, first.GroupID, again.GroupID) - assert.Equal(t, int64(40), again.Points) - assert.Equal(t, int64(60), e.coinBalance(t, c)) - assert.Len(t, e.repo.transactions, 3) - - // The same key for a different amount is not a retry. - _, err = e.exchanges().Exchange(e.ctx, c, 20, "482913", "key-1", models.CustomerPinRequestInfo{}) - assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) -} - -func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) { - e := newWalletMoveEnv(t) - c := e.member("Budi", "081234561234") - e.earnCoins(t, c, 5, nil) - - _, err := e.exchanges().Exchange(e.ctx, c, 5, "482913", " ", models.CustomerPinRequestInfo{}) - assert.ErrorIs(t, err, ErrWalletMoveRejected) - assert.Empty(t, e.pins.actions) -} - -// F4: EnakPoint from an exchange expire at the sooner of the EnakCoin lot's expiry and -// when EnakPoint received now would expire. -func TestWalletExchange_CappedByThePointExpiry(t *testing.T) { - e := newWalletMoveEnv(t) - e.now = wib(2026, 6, 1, 10, 0) - e.settings.PointExpiry = rolling(30, "DAY", false) - c := e.member("Budi", "081234561234") - soon, later := e.at(24*time.Hour), e.at(90*24*time.Hour) - e.earnCoins(t, c, 10, soon) - e.earnCoins(t, c, 10, later) - e.earnCoins(t, c, 10, nil) - - res, err := e.exchanges().Exchange(e.ctx, c, 30, "482913", "key-1", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - require.Len(t, res.Lots, 3) - assert.Equal(t, soon, res.Lots[0].ExpiresAt, "the EnakCoin lot expires first") - assert.Equal(t, "2026-07-01", expiryDay(t, res.Lots[1].ExpiresAt), "30 days of EnakPoint come first") - assert.Equal(t, "2026-07-01", expiryDay(t, res.Lots[2].ExpiresAt), "a lot that never expired gets the EnakPoint expiry") -} diff --git a/internal/processor/wallet_expiry_processor.go b/internal/processor/wallet_expiry_processor.go deleted file mode 100644 index b322362..0000000 --- a/internal/processor/wallet_expiry_processor.go +++ /dev/null @@ -1,196 +0,0 @@ -package processor - -import ( - "context" - "fmt" - "strconv" - "time" - - "github.com/google/uuid" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/repository" -) - -const ( - // Lots expired per query; a run keeps going until nothing is due. - walletExpiryBatchSize = 500 - // Batches per run at most, so one run cannot run away. - walletExpiryMaxBatches = 40 -) - -// NotificationTypeWalletExpired is the data type of the push a customer gets when -// part of their balance expires. -const NotificationTypeWalletExpired = "WALLET_EXPIRED" - -// NotificationTypeWalletExpiring is the data type of the reminder a customer gets -// before part of their balance expires. -const NotificationTypeWalletExpiring = "WALLET_EXPIRING" - -// WalletExpiryProcessor takes what is left in lots whose expiry has passed -// (docs/prd-point-coin.md F12, PC-503). It is safe to run on several instances at -// once: every lot is expired under its wallet's lock with the key expire:{lot_id}. -type WalletExpiryProcessor struct { - repo repository.WalletExpiryRepository - settings organizationSettingsReader - wallet *WalletProcessor - tx TxRunner - notifier customerNotifier - now func() time.Time -} - -func NewWalletExpiryProcessor(repo repository.WalletExpiryRepository, settings organizationSettingsReader, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletExpiryProcessor { - return &WalletExpiryProcessor{repo: repo, settings: settings, wallet: wallet, tx: tx, notifier: notifier, now: time.Now} -} - -type walletExpiredKey struct { - customerID uuid.UUID - currency string -} - -// ExpireDue expires every lot due now and tells each customer how much of each -// currency they lost, in one push per currency. It returns how many lots it expired. -// A lot that fails is logged and left for the next run; it does not stop the others. -func (p *WalletExpiryProcessor) ExpireDue(ctx context.Context) (int, error) { - asOf := p.now() - expired := map[walletExpiredKey]int64{} - count := 0 - for batch := 0; batch < walletExpiryMaxBatches; batch++ { - due, err := p.repo.ListDueLots(ctx, asOf, walletExpiryBatchSize) - if err != nil { - p.notify(ctx, expired) - return count, err - } - progressed := false - for _, lot := range due { - var res *WalletResult - err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { - var err error - res, err = p.wallet.ExpireLot(ctx, lot.ID, func(amount int64) string { - return expiryDescription(amount, lot.Currency, lot.SourceDescription) - }, asOf) - return err - }) - if err != nil { - logger.NonContext.Error(fmt.Sprintf("Could not expire wallet lot %s; it will be retried", lot.ID), err) - continue - } - if res == nil || res.Transaction == nil || res.Replayed { - // Another run got there first, or a payment used it up. - continue - } - progressed = true - count++ - expired[walletExpiredKey{lot.CustomerID, lot.Currency}] += -res.Transaction.Amount - } - // A short batch was the last; a batch that moved nothing would only come back - // the same, whether failing or taken by another instance. - if len(due) < walletExpiryBatchSize || !progressed { - break - } - } - p.notify(ctx, expired) - return count, nil -} - -// notify is best effort: the balance has already expired. -func (p *WalletExpiryProcessor) notify(ctx context.Context, expired map[walletExpiredKey]int64) { - if p.notifier == nil { - return - } - for key, amount := range expired { - name := walletCurrencyName(key.currency) - data := map[string]string{ - "type": NotificationTypeWalletExpired, - "currency": key.currency, - "amount": strconv.FormatInt(amount, 10), - } - body := fmt.Sprintf("%d %s kamu sudah kedaluwarsa.", amount, name) - if err := p.notifier.Notify(ctx, key.customerID, name+" kedaluwarsa", body, data); err != nil { - logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about expired %s", key.customerID, name), err) - } - } -} - -// expiryDescription is the EXPIRE row's frozen description (§8.1): -// "Kedaluwarsa: 150 EnakPoint dari Belanja #ORD-0098". -func expiryDescription(amount int64, currency, sourceDescription string) string { - description := fmt.Sprintf("Kedaluwarsa: %d %s", amount, walletCurrencyName(currency)) - if sourceDescription != "" { - description += " dari " + sourceDescription - } - return truncateRunes(description, walletDescriptionLimit) -} - -// SendReminders tells customers, reminder_days before, how much of their balance -// expires on a day (F12): one push per customer, currency and expiry day, however many -// lots make it up. A reminder is recorded before it is sent, so another instance or a -// later run never sends it again; a push that then fails is logged and not retried. -// It returns how many reminders it sent. -func (p *WalletExpiryProcessor) SendReminders(ctx context.Context) (int, error) { - now := p.now() - organizations, err := p.repo.OrganizationsWithUpcomingExpiry(ctx, now) - if err != nil { - return 0, err - } - sent := 0 - for _, organizationID := range organizations { - settings, err := p.settings.Organization(ctx, organizationID) - if err != nil { - logger.NonContext.Error(fmt.Sprintf("Could not read the expiry settings of organization %s; its reminders wait for the next run", organizationID), err) - continue - } - for _, currency := range []string{constants.WalletCurrencyPoint, constants.WalletCurrencyCoin} { - days := ExpirySettings(settings, currency).ReminderDays - if days <= 0 { - continue - } - until := endOfWalletDay(walletDay(now).AddDate(0, 0, int(days))) - upcoming, err := p.repo.UpcomingUnreminded(ctx, organizationID, currency, now, *until) - if err != nil { - return sent, err - } - for _, u := range upcoming { - first, err := p.repo.MarkReminded(ctx, u, currency) - if err != nil { - return sent, err - } - if !first { - continue - } - sent++ - p.remind(ctx, u, currency) - } - } - } - return sent, nil -} - -func (p *WalletExpiryProcessor) remind(ctx context.Context, u repository.UpcomingExpiry, currency string) { - if p.notifier == nil { - return - } - name := walletCurrencyName(currency) - body := fmt.Sprintf("%d %s akan kedaluwarsa pada %s. Pakai sebelum hangus.", u.Amount, name, formatWalletDate(u.Date)) - data := map[string]string{ - "type": NotificationTypeWalletExpiring, - "currency": currency, - "amount": strconv.FormatInt(u.Amount, 10), - "expiry_date": u.Date, - } - if err := p.notifier.Notify(ctx, u.CustomerID, name+" akan kedaluwarsa", body, data); err != nil { - logger.NonContext.Error(fmt.Sprintf("Could not remind customer %s of expiring %s", u.CustomerID, name), err) - } -} - -var walletMonthNames = [...]string{"Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"} - -// formatWalletDate writes a YYYY-MM-DD date the way the apps do: "31 Okt 2026". -func formatWalletDate(date string) string { - d, err := time.Parse("2006-01-02", date) - if err != nil { - return date - } - return fmt.Sprintf("%d %s %d", d.Day(), walletMonthNames[d.Month()-1], d.Year()) -} diff --git a/internal/processor/wallet_expiry_processor_test.go b/internal/processor/wallet_expiry_processor_test.go deleted file mode 100644 index d5bbbe8..0000000 --- a/internal/processor/wallet_expiry_processor_test.go +++ /dev/null @@ -1,244 +0,0 @@ -package processor - -import ( - "context" - "sort" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/repository" -) - -// walletExpiryRepoFake lists the fake wallet's due lots the way the query does, plus -// any extra lots a test wants listed. -type walletExpiryRepoFake struct { - wallet *walletRepoFake - extra []repository.DueLot - // customer/currency/date of the reminders recorded. - reminded map[string]bool -} - -func (f *walletExpiryRepoFake) ListDueLots(_ context.Context, asOf time.Time, limit int) ([]repository.DueLot, error) { - descriptions := map[uuid.UUID]string{} - for _, tx := range f.wallet.transactions { - descriptions[tx.ID] = tx.Description - } - due := append([]repository.DueLot(nil), f.extra...) - for _, lot := range f.wallet.lots { - if lot.RemainingAmount > 0 && lot.ExpiresAt != nil && !lot.ExpiresAt.After(asOf) { - due = append(due, repository.DueLot{ - ID: lot.ID, CustomerID: lot.CustomerID, Currency: lot.Currency, Remaining: lot.RemainingAmount, - ExpiresAt: *lot.ExpiresAt, SourceDescription: descriptions[lot.SourceTransactionID], - }) - } - } - sort.SliceStable(due, func(i, j int) bool { return due[i].ExpiresAt.Before(due[j].ExpiresAt) }) - if len(due) > limit { - due = due[:limit] - } - return due, nil -} - -func (e *walletMoveEnv) expiry(notifier customerNotifier) (*WalletExpiryProcessor, *walletExpiryRepoFake) { - repo := &walletExpiryRepoFake{wallet: e.repo} - p := NewWalletExpiryProcessor(repo, e, e.p, txRunnerFake{}, notifier) - p.now = func() time.Time { return e.now } - return p, repo -} - -func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - ord := earn(a, 150, e.at(-time.Hour)) - ord.Description = "Belanja #ORD-0098" - due := e.credit(t, ord) - e.credit(t, earn(a, 50, e.at(-2*time.Hour))) - e.credit(t, earn(a, 70, e.at(time.Hour))) // not yet - e.credit(t, earn(a, 30, nil)) // never - e.earnCoins(t, a, 4, e.at(-time.Minute)) - // A payment already used part of the first lot; only the rest expires. - _, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: pay(a, 20).WalletEntry, PreferredLotIDs: []uuid.UUID{due.Lots[0].ID}}) - require.NoError(t, err) - - notifier := ¬ifierFake{} - p, _ := e.expiry(notifier) - count, err := p.ExpireDue(e.ctx) - require.NoError(t, err) - - assert.Equal(t, 3, count) - assert.Equal(t, int64(100), e.balance(t, a), "70 not due yet + 30 that never expires") - assert.Equal(t, int64(0), e.coinBalance(t, a)) - - var expire *entities.WalletTransaction - for _, tx := range e.repo.transactions { - if tx.Type == constants.WalletTxTypeExpire && tx.ReferenceID == due.Lots[0].ID { - expire = tx - } - } - require.NotNil(t, expire) - assert.Equal(t, int64(-130), expire.Amount) - assert.Equal(t, "Kedaluwarsa: 130 EnakPoint dari Belanja #ORD-0098", expire.Description) - assert.Equal(t, constants.WalletRefTypeLot, expire.ReferenceType) - assert.Equal(t, "expire:"+due.Lots[0].ID.String(), *expire.IdempotencyKey) - - // One push per currency, with the total. - pushes := notifier.pushes[a] - require.Len(t, pushes, 2) - byCurrency := map[string]pushFake{} - for _, p := range pushes { - byCurrency[p.data["currency"]] = p - } - assert.Equal(t, "EnakPoint kedaluwarsa", byCurrency["POINT"].title) - assert.Equal(t, "180 EnakPoint kamu sudah kedaluwarsa.", byCurrency["POINT"].body) - assert.Equal(t, NotificationTypeWalletExpired, byCurrency["POINT"].data["type"]) - assert.Equal(t, "4", byCurrency["COIN"].data["amount"]) -} - -func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - e.credit(t, earn(a, 150, e.at(-time.Hour))) - notifier := ¬ifierFake{} - - // Two instances, one after the other. - first, _ := e.expiry(notifier) - second, _ := e.expiry(notifier) - n1, err := first.ExpireDue(e.ctx) - require.NoError(t, err) - n2, err := second.ExpireDue(e.ctx) - require.NoError(t, err) - - assert.Equal(t, 1, n1) - assert.Equal(t, 0, n2) - assert.Len(t, notifier.pushes[a], 1) - var expires int - for _, tx := range e.repo.transactions { - if tx.Type == constants.WalletTxTypeExpire { - expires++ - } - } - assert.Equal(t, 1, expires) -} - -func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - e.credit(t, earn(a, 150, e.at(-time.Hour))) - p, repo := e.expiry(nil) - // A lot listed that ExpireLot cannot find. - repo.extra = []repository.DueLot{{ID: uuid.New(), CustomerID: a, Currency: "POINT", Remaining: 5, ExpiresAt: e.now.Add(-3 * time.Hour)}} - - count, err := p.ExpireDue(e.ctx) - require.NoError(t, err) - assert.Equal(t, 1, count) - assert.Equal(t, int64(0), e.balance(t, a)) -} - -func TestWalletExpiry_NothingDue(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - e.credit(t, earn(a, 150, e.at(time.Hour))) - notifier := ¬ifierFake{} - p, _ := e.expiry(notifier) - - count, err := p.ExpireDue(e.ctx) - require.NoError(t, err) - assert.Zero(t, count) - assert.Empty(t, notifier.pushes) -} - -func (f *walletExpiryRepoFake) OrganizationsWithUpcomingExpiry(_ context.Context, asOf time.Time) ([]uuid.UUID, error) { - seen := map[uuid.UUID]bool{} - var out []uuid.UUID - for _, lot := range f.wallet.lots { - if lot.RemainingAmount > 0 && lot.ExpiresAt != nil && lot.ExpiresAt.After(asOf) && !seen[lot.OrganizationID] { - seen[lot.OrganizationID] = true - out = append(out, lot.OrganizationID) - } - } - return out, nil -} - -func (f *walletExpiryRepoFake) UpcomingUnreminded(_ context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]repository.UpcomingExpiry, error) { - sums := map[[2]string]int64{} - var order [][2]string - for _, lot := range f.wallet.lots { - if lot.OrganizationID != organizationID || lot.Currency != currency || lot.RemainingAmount == 0 || - lot.ExpiresAt == nil || !lot.ExpiresAt.After(asOf) || lot.ExpiresAt.After(until) { - continue - } - key := [2]string{lot.CustomerID.String(), lot.ExpiresAt.In(walletDisplayLocation).Format("2006-01-02")} - if f.reminded[key[0]+"/"+currency+"/"+key[1]] { - continue - } - if _, ok := sums[key]; !ok { - order = append(order, key) - } - sums[key] += lot.RemainingAmount - } - var out []repository.UpcomingExpiry - for _, key := range order { - out = append(out, repository.UpcomingExpiry{CustomerID: uuid.MustParse(key[0]), Date: key[1], Amount: sums[key]}) - } - return out, nil -} - -func (f *walletExpiryRepoFake) MarkReminded(_ context.Context, u repository.UpcomingExpiry, currency string) (bool, error) { - if f.reminded == nil { - f.reminded = map[string]bool{} - } - key := u.CustomerID.String() + "/" + currency + "/" + u.Date - if f.reminded[key] { - return false, nil - } - f.reminded[key] = true - return true, nil -} - -func TestWalletExpiry_RemindsOncePerDayBeforeExpiry(t *testing.T) { - e := newWalletMoveEnv(t) - e.now = wib(2026, 10, 25, 9, 0) - e.settings.PointExpiry.ReminderDays = 7 - e.settings.CoinExpiry.ReminderDays = 0 // no reminders for EnakCoin - a := e.member("Anita", "081200005678") - oct31 := wib(2026, 10, 31, 23, 59) - nov30 := wib(2026, 11, 30, 23, 59) - e.credit(t, earn(a, 100, &oct31)) - e.credit(t, earn(a, 50, &oct31)) - e.credit(t, earn(a, 70, &nov30)) // too far off yet - e.earnCoins(t, a, 5, &oct31) - notifier := ¬ifierFake{} - p, _ := e.expiry(notifier) - - sent, err := p.SendReminders(e.ctx) - require.NoError(t, err) - assert.Equal(t, 1, sent) - require.Len(t, notifier.pushes[a], 1) - push := notifier.pushes[a][0] - assert.Equal(t, "EnakPoint akan kedaluwarsa", push.title) - assert.Equal(t, "150 EnakPoint akan kedaluwarsa pada 31 Okt 2026. Pakai sebelum hangus.", push.body) - assert.Equal(t, map[string]string{"type": NotificationTypeWalletExpiring, "currency": "POINT", "amount": "150", "expiry_date": "2026-10-31"}, push.data) - - // The next run, on this instance or another, sends nothing again. - again, err := p.SendReminders(e.ctx) - require.NoError(t, err) - assert.Zero(t, again) - - // Once 30 Nov comes within seven days, it gets its own reminder. - e.now = wib(2026, 11, 23, 9, 0) - sent, err = p.SendReminders(e.ctx) - require.NoError(t, err) - assert.Equal(t, 1, sent) - assert.Equal(t, "70", notifier.pushes[a][1].data["amount"]) -} - -func TestFormatWalletDate(t *testing.T) { - assert.Equal(t, "31 Okt 2026", formatWalletDate("2026-10-31")) - assert.Equal(t, "1 Mei 2027", formatWalletDate("2027-05-01")) -} diff --git a/internal/processor/wallet_migration_processor.go b/internal/processor/wallet_migration_processor.go deleted file mode 100644 index 16aaef6..0000000 --- a/internal/processor/wallet_migration_processor.go +++ /dev/null @@ -1,184 +0,0 @@ -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 -} diff --git a/internal/processor/wallet_migration_processor_db_test.go b/internal/processor/wallet_migration_processor_db_test.go deleted file mode 100644 index c0c3f3d..0000000 --- a/internal/processor/wallet_migration_processor_db_test.go +++ /dev/null @@ -1,167 +0,0 @@ -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 -} diff --git a/internal/processor/wallet_move_db_test.go b/internal/processor/wallet_move_db_test.go deleted file mode 100644 index 0808d2f..0000000 --- a/internal/processor/wallet_move_db_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package processor - -import ( - "context" - "fmt" - "os" - "sync" - "testing" - "time" - - "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/models" - "apskel-pos-be/internal/repository" -) - -// fixedOrganizationSettings serves the same organization settings to every caller. -type fixedOrganizationSettings struct { - s models.OrganizationLoyaltySettings -} - -func (f fixedOrganizationSettings) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) { - s := f.s - return &s, nil -} - -// walletMoveDB opens TEST_DATABASE_URL and creates an organization with two customers, -// removed again when the test ends. See internal/repository/wallet_repository_test.go. -func walletMoveDB(t *testing.T) (db *gorm.DB, org, a, b uuid.UUID) { - t.Helper() - 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) - - org, a, b = uuid.New(), uuid.New(), uuid.New() - phoneA, phoneB := "08"+a.String()[:10], "08"+b.String()[:10] - require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet move test', 'basic')`, org).Error) - require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Anita', ?), (?, ?, 'Budi Santoso', ?)`, - a, org, phoneA, b, org, phoneB).Error) - customers := []uuid.UUID{a, b} - 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 ? AND origin_lot_id IS NOT NULL`, 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) - }) - return db, org, a, b -} - -func TestWalletExchange_AgainstPostgres(t *testing.T) { - db, _, a, _ := walletMoveDB(t) - wallet := NewWalletProcessor(repository.NewWalletRepository(db)) - txm := repository.NewTxManager(db) - settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{ - Exchange: models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}, - }} - p := NewWalletExchangeProcessor(repository.NewWalletMoveRepository(db), settings, repository.NewWalletQueryRepository(db), - &movePinFake{good: "482913"}, wallet, txm) - - expiry := time.Now().Add(24 * time.Hour).Truncate(time.Second) - require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { - in := earn(a, 30, &expiry) - in.Currency = constants.WalletCurrencyCoin - _, err := wallet.Credit(ctx, in) - return err - })) - - res, err := p.Exchange(context.Background(), a, 20, "482913", "db-key", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - assert.Equal(t, int64(6), res.Points) - assert.Equal(t, int64(10), res.CoinBalance) - assert.Equal(t, int64(6), res.PointBalance) - require.Len(t, res.Lots, 1) - require.NotNil(t, res.Lots[0].ExpiresAt) - assert.True(t, res.Lots[0].ExpiresAt.Equal(expiry)) - - // The retry reads the frozen rate back out of JSONB and replays. - again, err := p.Exchange(context.Background(), a, 20, "482913", "db-key", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - assert.True(t, again.Replayed) - assert.Equal(t, int64(6), again.Points) - - var rows int64 - require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE group_id = ?`, res.GroupID).Scan(&rows).Error) - assert.Equal(t, int64(2), rows) -} - -// Transfers in both directions at once must not deadlock: both lock the two wallets -// in customer_id order. Every one of them lands, and the totals still reconcile. -func TestWalletTransfer_BothWaysAtOnceAgainstPostgres(t *testing.T) { - db, _, a, b := walletMoveDB(t) - wallet := NewWalletProcessor(repository.NewWalletRepository(db)) - txm := repository.NewTxManager(db) - moves := repository.NewWalletMoveRepository(db) - settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{ - Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1}, - }} - p := NewWalletTransferProcessor(moves, settings, repository.NewWalletQueryRepository(db), &movePinFake{good: "482913"}, wallet, txm, nil) - - expiry := time.Now().Add(24 * time.Hour).Truncate(time.Second) - require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { - if _, err := wallet.Credit(ctx, earn(a, 100, &expiry)); err != nil { - return err - } - _, err := wallet.Credit(ctx, earn(b, 100, nil)) - return err - })) - phone := func(id uuid.UUID) string { return "08" + id.String()[:10] } - - const rounds = 10 - errs := make(chan error, 2*rounds) - var wg sync.WaitGroup - for i := 0; i < rounds; i++ { - for _, pair := range [][2]uuid.UUID{{a, b}, {b, a}} { - wg.Add(1) - go func(from, to uuid.UUID, i int) { - defer wg.Done() - _, err := p.Transfer(context.Background(), from, sendPoints(1, phone(to)), "482913", fmt.Sprintf("race-%d", i), models.CustomerPinRequestInfo{}) - errs <- err - }(pair[0], pair[1], i) - } - } - wg.Wait() - close(errs) - for err := range errs { - assert.NoError(t, err) - } - - var balances []int64 - require.NoError(t, db.Raw(`SELECT point_balance FROM customer_wallets WHERE customer_id IN ? ORDER BY point_balance`, []uuid.UUID{a, b}).Scan(&balances).Error) - assert.Equal(t, []int64{100, 100}, balances) - - // B's lots that came from A keep A's expiry to the second. - var mismatched int64 - require.NoError(t, db.Raw(` - SELECT COUNT(*) FROM wallet_lots l JOIN wallet_lots o ON o.id = l.origin_lot_id - WHERE l.customer_id = ? AND o.customer_id = ? AND l.expires_at IS DISTINCT FROM o.expires_at`, b, a).Scan(&mismatched).Error) - assert.Zero(t, mismatched) - - require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { - sent, err := moves.TransferredOutSince(ctx, a, constants.WalletCurrencyPoint, startOfWalletDay(time.Now())) - assert.Equal(t, int64(rounds), sent) - return err - })) -} - -// The example of §8 against Postgres: B's payment of 30 traces back to A's #ORD-1. -func TestWalletTrace_AgainstPostgres(t *testing.T) { - db, org, a, b := walletMoveDB(t) - wallet := NewWalletProcessor(repository.NewWalletRepository(db)) - txm := repository.NewTxManager(db) - settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{ - Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1}, - }} - transfers := NewWalletTransferProcessor(repository.NewWalletMoveRepository(db), settings, repository.NewWalletQueryRepository(db), &movePinFake{good: "482913"}, wallet, txm, nil) - - dec, jan := time.Now().Add(30*24*time.Hour), time.Now().Add(60*24*time.Hour) - ord1 := earn(a, 100, &dec) - ord1.Description = "Belanja #ORD-1" - require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { - if _, err := wallet.Credit(ctx, ord1); err != nil { - return err - } - _, err := wallet.Credit(ctx, earn(a, 50, &jan)) - return err - })) - _, err := transfers.Transfer(context.Background(), a, sendPoints(120, "08"+b.String()[:10]), "482913", "trace", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - var payment *WalletResult - require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { - payment, err = wallet.Debit(ctx, pay(b, 30)) - return err - })) - - trace, err := NewWalletTraceProcessor(repository.NewWalletTraceRepository(db)).Trace(context.Background(), org, payment.Transaction.ID) - require.NoError(t, err) - require.Len(t, trace.Lots, 1) - chain := trace.Lots[0].Chain - require.Len(t, chain, 2) - assert.Equal(t, constants.WalletTxTypeTransferIn, chain[0].Source.Type) - assert.Equal(t, "Anita", chain[1].Source.Customer.Name) - assert.Equal(t, ord1.ReferenceID, chain[1].Source.ReferenceID) - - _, err = NewWalletTraceProcessor(repository.NewWalletTraceRepository(db)).Trace(context.Background(), uuid.New(), payment.Transaction.ID) - assert.ErrorIs(t, err, repository.ErrWalletTransactionNotFound) -} - -// Two instances of the expiry job at once expire each lot exactly once (PC-503). -func TestWalletExpiry_TwoInstancesAgainstPostgres(t *testing.T) { - db, _, a, b := walletMoveDB(t) - wallet := NewWalletProcessor(repository.NewWalletRepository(db)) - txm := repository.NewTxManager(db) - past := time.Now().Add(-time.Hour) - require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { - for i := 0; i < 5; i++ { - for _, c := range []uuid.UUID{a, b} { - if _, err := wallet.Credit(ctx, earn(c, 10, &past)); err != nil { - return err - } - } - } - return nil - })) - - var wg sync.WaitGroup - counts := make([]int, 2) - for i := range counts { - wg.Add(1) - go func(i int) { - defer wg.Done() - p := NewWalletExpiryProcessor(repository.NewWalletExpiryRepository(db), fixedOrganizationSettings{}, wallet, txm, nil) - n, err := p.ExpireDue(context.Background()) - assert.NoError(t, err) - counts[i] = n - }(i) - } - wg.Wait() - assert.Equal(t, 10, counts[0]+counts[1], "every lot once, between them") - - var expires, left int64 - require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id IN ? AND type = 'EXPIRE'`, []uuid.UUID{a, b}).Scan(&expires).Error) - require.NoError(t, db.Raw(`SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets WHERE customer_id IN ?`, []uuid.UUID{a, b}).Scan(&left).Error) - assert.Equal(t, int64(10), expires) - assert.Zero(t, left) -} diff --git a/internal/processor/wallet_processor.go b/internal/processor/wallet_processor.go deleted file mode 100644 index 17bb8de..0000000 --- a/internal/processor/wallet_processor.go +++ /dev/null @@ -1,583 +0,0 @@ -package processor - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/repository" -) - -var ( - // ErrWalletInvalidEntry wraps every rejection of an entry that breaks the rules in - // docs/prd-point-coin.md §8.1. The database enforces most of them too; checking - // here first gives callers a readable error instead of a constraint name. - ErrWalletInvalidEntry = errors.New("wallet: invalid entry") - // ErrWalletIdempotencyConflict means an idempotency key was reused for a different - // operation. Retrying the same operation with the same key is not a conflict. - ErrWalletIdempotencyConflict = errors.New("wallet: idempotency key already used for a different operation") -) - -// WalletEntry is what every ledger row needs, whichever way it moves the balance. -// Which of the optional fields a type requires is listed in §8.1. -type WalletEntry struct { - // Optional. Set it when another row must reference this one before it exists, as - // the two rows of an exchange or a transfer do. - TransactionID uuid.UUID - - CustomerID uuid.UUID - Currency string - Type string - // Always positive: Credit adds it, Debit takes it away. - Amount int64 - - ReferenceType string - ReferenceID uuid.UUID - - GroupID *uuid.UUID - CounterpartyCustomerID *uuid.UUID - ReversesTransactionID *uuid.UUID - OutletID *uuid.UUID - CreatedByUser *uuid.UUID - Reason *string - - Description string - Metadata entities.Metadata - // Optional. A retry with the same key returns the first result without moving - // anything again. - IdempotencyKey string -} - -// WalletLotInput is one lot a credit creates. -type WalletLotInput struct { - Amount int64 - // Nil means the lot never expires. - ExpiresAt *time.Time - // The lot this one was carried over from, for transfers, exchanges and refunds. - OriginLotID *uuid.UUID -} - -type WalletCreditInput struct { - WalletEntry - // How the credit is split into lots. Their amounts must add up to Amount. Leave - // empty for a single lot that never expires. - Lots []WalletLotInput -} - -type WalletDebitInput struct { - WalletEntry - // Lots to draw from first, in this order, before falling back to the K9 order. - // A reversal names the lots its EARN created (F10), and the expiry job names the - // lot that expired. These lots are used even if they have already expired. - PreferredLotIDs []uuid.UUID -} - -// WalletAllocation is how much a debit took from one lot. It carries the lot's -// expiry, so a transfer or exchange can give the receiving lot the same expiry (K9). -type WalletAllocation struct { - LotID uuid.UUID - Amount int64 - ExpiresAt *time.Time -} - -type WalletResult struct { - // Nil only when DebitUpTo found nothing to take. - Transaction *entities.WalletTransaction - // The lots a credit created. - Lots []entities.WalletLot - // The lots a debit drew from, in the order they were used. - Allocations []WalletAllocation - // What DebitUpTo could not take because the balance ran out. - Shortfall int64 - // True when the idempotency key had already been used and nothing moved. - Replayed bool -} - -// CarryOver turns a debit's allocations into lots for the receiving side of a -// transfer or exchange. Each lot keeps the expiry of the lot it came from and points -// back at it, so a balance cannot be kept alive by moving it around (K9). -func (r *WalletResult) CarryOver() []WalletLotInput { - lots := make([]WalletLotInput, 0, len(r.Allocations)) - for _, a := range r.Allocations { - lotID := a.LotID - lots = append(lots, WalletLotInput{Amount: a.Amount, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID}) - } - return lots -} - -// WalletProcessor is the only code allowed to change a wallet balance. Every change -// writes the balance, the ledger row and the lots or allocations together, which is -// what keeps SUM(ledger) = balance = SUM(lot remaining) (§7.5). -// -// Every method must run inside a transaction from TxManager, and the repository -// refuses otherwise. Each method locks the customer's wallet itself, so a single-wallet -// caller needs nothing more. A caller touching two wallets, such as a transfer, must -// call LockWallets first so the locks are always taken in the same order. -type WalletProcessor struct { - repo repository.WalletRepository - now func() time.Time -} - -func NewWalletProcessor(repo repository.WalletRepository) *WalletProcessor { - return &WalletProcessor{repo: repo, now: time.Now} -} - -// LockWallet locks one customer's wallet, creating it if needed. Credit and Debit do -// this themselves; call it when something must be read under the lock first. -func (p *WalletProcessor) LockWallet(ctx context.Context, customerID uuid.UUID) error { - _, err := p.repo.LockWallet(ctx, customerID) - return err -} - -// LockWallets locks two customers' wallets in a fixed order. Call it before touching -// both wallets in one transaction. -func (p *WalletProcessor) LockWallets(ctx context.Context, a, b uuid.UUID) error { - _, _, err := p.repo.LockWallets(ctx, a, b) - return err -} - -// FindTransaction returns the row written under an idempotency key, or nil when the -// key is new. Read it under the wallet lock: a flow that writes several rows uses it -// to recognise a retry before checking limits the first attempt already counts in. -func (p *WalletProcessor) FindTransaction(ctx context.Context, idempotencyKey string) (*entities.WalletTransaction, error) { - return p.repo.GetTransactionByIdempotencyKey(ctx, idempotencyKey) -} - -// ExpireLot takes what is left in a lot whose expiry has passed at asOf, as an EXPIRE -// row pointing at the lot (F12). It locks the wallet before reading the lot again, so -// it never races a payment for the same balance, and the key expire:{lot_id} makes a -// second run, on this instance or another, take nothing more. describe gives the -// row's description for the amount taken. It returns nil when -// there is nothing to take: the lot is empty, not due, or already expired. -func (p *WalletProcessor) ExpireLot(ctx context.Context, lotID uuid.UUID, describe func(amount int64) string, asOf time.Time) (*WalletResult, error) { - lot, err := p.getLot(ctx, lotID) - if err != nil { - return nil, err - } - if _, err := p.repo.LockWallet(ctx, lot.CustomerID); err != nil { - return nil, err - } - // Read again under the lock: a payment may have used it up meanwhile. - if lot, err = p.getLot(ctx, lotID); err != nil { - return nil, err - } - if lot.RemainingAmount == 0 || lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf) { - return nil, nil - } - return p.Debit(ctx, WalletDebitInput{ - WalletEntry: WalletEntry{ - CustomerID: lot.CustomerID, - Currency: lot.Currency, - Type: constants.WalletTxTypeExpire, - Amount: lot.RemainingAmount, - ReferenceType: constants.WalletRefTypeLot, - ReferenceID: lot.ID, - Description: describe(lot.RemainingAmount), - Metadata: entities.Metadata{"expires_at": lot.ExpiresAt.UTC().Format(time.RFC3339)}, - IdempotencyKey: "expire:" + lot.ID.String(), - }, - // Exactly what the lot holds, from the lot itself, even though it has expired. - PreferredLotIDs: []uuid.UUID{lot.ID}, - }) -} - -func (p *WalletProcessor) getLot(ctx context.Context, lotID uuid.UUID) (*entities.WalletLot, error) { - lots, err := p.repo.GetLotsByIDs(ctx, []uuid.UUID{lotID}) - if err != nil { - return nil, err - } - if len(lots) == 0 { - return nil, fmt.Errorf("%w: lot %s does not exist", ErrWalletInvalidEntry, lotID) - } - return &lots[0], nil -} - -// Credit adds Amount to the wallet and creates its lots. -func (p *WalletProcessor) Credit(ctx context.Context, in WalletCreditInput) (*WalletResult, error) { - if err := validateWalletEntry(&in.WalletEntry, true); err != nil { - return nil, err - } - lots := in.Lots - if len(lots) == 0 { - lots = []WalletLotInput{{Amount: in.Amount}} - } - var total int64 - for _, lot := range lots { - if lot.Amount <= 0 { - return nil, fmt.Errorf("%w: lot amount must be positive, got %d", ErrWalletInvalidEntry, lot.Amount) - } - total += lot.Amount - } - if total != in.Amount { - return nil, fmt.Errorf("%w: lots add up to %d, not %d", ErrWalletInvalidEntry, total, in.Amount) - } - - wallet, err := p.repo.LockWallet(ctx, in.CustomerID) - if err != nil { - return nil, err - } - if replay, err := p.replay(ctx, &in.WalletEntry, true, true); replay != nil || err != nil { - return replay, err - } - - balance, err := p.repo.AddBalance(ctx, in.CustomerID, in.Currency, in.Amount) - if err != nil { - return nil, err - } - walletTx := newWalletTransaction(wallet, &in.WalletEntry, in.Amount, balance, nil) - if err := p.repo.CreateTransaction(ctx, walletTx); err != nil { - return nil, fmt.Errorf("failed to create wallet transaction: %w", err) - } - - result := &WalletResult{Transaction: walletTx} - for _, lotIn := range lots { - lot := entities.WalletLot{ - OrganizationID: wallet.OrganizationID, - CustomerID: in.CustomerID, - Currency: in.Currency, - SourceTransactionID: walletTx.ID, - OriginLotID: lotIn.OriginLotID, - OriginalAmount: lotIn.Amount, - RemainingAmount: lotIn.Amount, - ExpiresAt: lotIn.ExpiresAt, - } - if err := p.repo.CreateLot(ctx, &lot); err != nil { - return nil, fmt.Errorf("failed to create wallet lot: %w", err) - } - result.Lots = append(result.Lots, lot) - } - return result, nil -} - -// Debit takes exactly Amount from the wallet, or nothing at all with -// repository.ErrWalletInsufficientBalance if the usable balance is short. -func (p *WalletProcessor) Debit(ctx context.Context, in WalletDebitInput) (*WalletResult, error) { - return p.debit(ctx, in, false) -} - -// DebitUpTo takes as much of Amount as the wallet has and reports the rest as -// Shortfall. It is for reversing earnings the customer has already spent (F10, Q3). -// When there is nothing to take, no ledger row is written and Transaction is nil; -// such a call leaves no trace, so a retry with the same key takes whatever the -// balance holds by then. -func (p *WalletProcessor) DebitUpTo(ctx context.Context, in WalletDebitInput) (*WalletResult, error) { - return p.debit(ctx, in, true) -} - -func (p *WalletProcessor) debit(ctx context.Context, in WalletDebitInput, upTo bool) (*WalletResult, error) { - if err := validateWalletEntry(&in.WalletEntry, false); err != nil { - return nil, err - } - wallet, err := p.repo.LockWallet(ctx, in.CustomerID) - if err != nil { - return nil, err - } - // DebitUpTo may have taken less than asked, so the amount cannot be compared. - if replay, err := p.replay(ctx, &in.WalletEntry, false, !upTo); replay != nil || err != nil { - return replay, err - } - - lots, err := p.spendableLots(ctx, &in) - if err != nil { - return nil, err - } - - var available int64 - for _, lot := range lots { - available += lot.RemainingAmount - } - take := in.Amount - if available < take { - if !upTo { - return nil, repository.ErrWalletInsufficientBalance - } - take = available - } - result := &WalletResult{Shortfall: in.Amount - take} - if take == 0 { - return result, nil - } - - var metadata entities.Metadata - if upTo { - metadata = entities.Metadata{"requested_amount": in.Amount, "shortfall": result.Shortfall} - } - - balance, err := p.repo.AddBalance(ctx, in.CustomerID, in.Currency, -take) - if err != nil { - return nil, err - } - walletTx := newWalletTransaction(wallet, &in.WalletEntry, -take, balance, metadata) - if err := p.repo.CreateTransaction(ctx, walletTx); err != nil { - return nil, fmt.Errorf("failed to create wallet transaction: %w", err) - } - result.Transaction = walletTx - - var allocations []entities.WalletLotAllocation - remaining := take - for _, lot := range lots { - if remaining == 0 { - break - } - amount := min(lot.RemainingAmount, remaining) - remaining -= amount - if err := p.repo.ConsumeLot(ctx, lot.ID, amount); err != nil { - return nil, err - } - allocations = append(allocations, entities.WalletLotAllocation{TransactionID: walletTx.ID, LotID: lot.ID, Amount: amount}) - result.Allocations = append(result.Allocations, WalletAllocation{LotID: lot.ID, Amount: amount, ExpiresAt: lot.ExpiresAt}) - } - if err := p.repo.CreateAllocations(ctx, allocations); err != nil { - return nil, fmt.Errorf("failed to create wallet lot allocations: %w", err) - } - return result, nil -} - -// spendableLots returns the lots a debit may draw from, in the order it draws: the -// preferred lots first, then the unexpired lots in K9 order. -func (p *WalletProcessor) spendableLots(ctx context.Context, in *WalletDebitInput) ([]entities.WalletLot, error) { - var lots []entities.WalletLot - preferred := make(map[uuid.UUID]bool, len(in.PreferredLotIDs)) - - if len(in.PreferredLotIDs) > 0 { - found, err := p.repo.GetLotsByIDs(ctx, in.PreferredLotIDs) - if err != nil { - return nil, err - } - byID := make(map[uuid.UUID]entities.WalletLot, len(found)) - for _, lot := range found { - byID[lot.ID] = lot - } - for _, id := range in.PreferredLotIDs { - lot, ok := byID[id] - if !ok || lot.CustomerID != in.CustomerID || lot.Currency != in.Currency { - return nil, fmt.Errorf("%w: lot %s is not a %s lot of this customer", ErrWalletInvalidEntry, id, in.Currency) - } - if preferred[id] { - continue - } - preferred[id] = true - if lot.RemainingAmount > 0 { - lots = append(lots, lot) - } - } - } - - active, err := p.repo.ListActiveLots(ctx, in.CustomerID, in.Currency, p.now()) - if err != nil { - return nil, err - } - for _, lot := range active { - if !preferred[lot.ID] { - lots = append(lots, lot) - } - } - return lots, nil -} - -// replay returns the first result for an idempotency key that has already been used, -// or nil when the key is new. It runs after the wallet lock, so a concurrent request -// with the same key has either committed its row or not started. -func (p *WalletProcessor) replay(ctx context.Context, in *WalletEntry, credit, compareAmount bool) (*WalletResult, error) { - if in.IdempotencyKey == "" { - return nil, nil - } - walletTx, err := p.repo.GetTransactionByIdempotencyKey(ctx, in.IdempotencyKey) - if err != nil || walletTx == nil { - return nil, err - } - - sameDirection := (walletTx.Amount > 0) == credit - sameAmount := !compareAmount || abs(walletTx.Amount) == in.Amount - if walletTx.CustomerID != in.CustomerID || walletTx.Currency != in.Currency || - walletTx.Type != in.Type || !sameDirection || !sameAmount { - return nil, ErrWalletIdempotencyConflict - } - - result := &WalletResult{Transaction: walletTx, Replayed: true} - if credit { - result.Lots, err = p.repo.ListLotsBySourceTransaction(ctx, walletTx.ID) - return result, err - } - - // JSON numbers come back from JSONB as float64. - switch shortfall := walletTx.Metadata["shortfall"].(type) { - case float64: - result.Shortfall = int64(shortfall) - case int64: - result.Shortfall = shortfall - } - allocations, err := p.repo.ListAllocationsByTransaction(ctx, walletTx.ID) - if err != nil { - return nil, err - } - ids := make([]uuid.UUID, 0, len(allocations)) - for _, a := range allocations { - ids = append(ids, a.LotID) - } - lots, err := p.repo.GetLotsByIDs(ctx, ids) - if err != nil { - return nil, err - } - expiry := make(map[uuid.UUID]*time.Time, len(lots)) - for _, lot := range lots { - expiry[lot.ID] = lot.ExpiresAt - } - for _, a := range allocations { - result.Allocations = append(result.Allocations, WalletAllocation{LotID: a.LotID, Amount: a.Amount, ExpiresAt: expiry[a.LotID]}) - } - return result, nil -} - -func newWalletTransaction(wallet *entities.CustomerWallet, in *WalletEntry, amount, balance int64, extra entities.Metadata) *entities.WalletTransaction { - metadata := entities.Metadata{} - for k, v := range in.Metadata { - metadata[k] = v - } - for k, v := range extra { - metadata[k] = v - } - var key *string - if in.IdempotencyKey != "" { - k := in.IdempotencyKey - key = &k - } - return &entities.WalletTransaction{ - ID: in.TransactionID, - OrganizationID: wallet.OrganizationID, - CustomerID: in.CustomerID, - Currency: in.Currency, - Type: in.Type, - Amount: amount, - BalanceAfter: balance, - GroupID: in.GroupID, - ReferenceType: in.ReferenceType, - ReferenceID: in.ReferenceID, - CounterpartyCustomerID: in.CounterpartyCustomerID, - ReversesTransactionID: in.ReversesTransactionID, - OutletID: in.OutletID, - CreatedByUser: in.CreatedByUser, - Reason: in.Reason, - Description: in.Description, - Metadata: metadata, - IdempotencyKey: key, - } -} - -// walletTypeRule is one row of §8.1. -type walletTypeRule struct { - credit, debit bool - currency string // empty: either currency - referenceTypes []string - needsOutlet bool - needsReverses bool - needsGroup bool - needsCounter bool - needsActor bool -} - -var walletTypeRules = map[string]walletTypeRule{ - constants.WalletTxTypeEarn: {credit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true}, - constants.WalletTxTypeEarnReversal: {debit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true, needsReverses: true}, - constants.WalletTxTypePayment: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true}, - constants.WalletTxTypePaymentRefund: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true, needsReverses: true}, - constants.WalletTxTypeExchangeOut: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true}, - constants.WalletTxTypeExchangeIn: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true}, - constants.WalletTxTypeTransferOut: {debit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true}, - constants.WalletTxTypeTransferIn: {credit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true}, - constants.WalletTxTypeGameSpend: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGamePlay}}, - constants.WalletTxTypeExpire: {debit: true, referenceTypes: []string{constants.WalletRefTypeLot}}, - constants.WalletTxTypeAdjustment: {credit: true, debit: true, referenceTypes: []string{constants.WalletRefTypeUser}, needsActor: true}, - constants.WalletTxTypeMigration: {credit: true, referenceTypes: []string{constants.WalletRefTypeLegacyPoints, constants.WalletRefTypeLegacyTokens}}, - constants.WalletTxTypeRewardRedeem: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeRewardRedemption}}, -} - -func validateWalletEntry(in *WalletEntry, credit bool) error { - invalid := func(format string, args ...any) error { - return fmt.Errorf("%w: %s", ErrWalletInvalidEntry, fmt.Sprintf(format, args...)) - } - - rule, ok := walletTypeRules[in.Type] - if !ok { - return invalid("unknown type %q", in.Type) - } - if credit && !rule.credit { - return invalid("%s cannot add to a balance", in.Type) - } - if !credit && !rule.debit { - return invalid("%s cannot take from a balance", in.Type) - } - if in.CustomerID == uuid.Nil { - return invalid("customer is required") - } - if !constants.IsValidWalletCurrency(in.Currency) { - return invalid("unknown currency %q", in.Currency) - } - if rule.currency != "" && in.Currency != rule.currency { - return invalid("%s must be in %s", in.Type, rule.currency) - } - if in.Amount <= 0 { - return invalid("amount must be positive, got %d", in.Amount) - } - if !containsString(rule.referenceTypes, in.ReferenceType) { - return invalid("%s must reference %s, got %q", in.Type, strings.Join(rule.referenceTypes, " or "), in.ReferenceType) - } - if in.ReferenceID == uuid.Nil { - return invalid("reference id is required") - } - if strings.TrimSpace(in.Description) == "" { - return invalid("description is required") - } - if rule.needsOutlet && isNilID(in.OutletID) { - return invalid("%s requires an outlet", in.Type) - } - if rule.needsReverses && isNilID(in.ReversesTransactionID) { - return invalid("%s requires the transaction it reverses", in.Type) - } - if rule.needsGroup && isNilID(in.GroupID) { - return invalid("%s requires a group id", in.Type) - } - if rule.needsCounter { - if isNilID(in.CounterpartyCustomerID) { - return invalid("%s requires a counterparty", in.Type) - } - if *in.CounterpartyCustomerID == in.CustomerID { - return invalid("%s cannot go to the same customer", in.Type) - } - } - if rule.needsActor { - if isNilID(in.CreatedByUser) { - return invalid("%s requires the admin who made it", in.Type) - } - if in.Reason == nil || strings.TrimSpace(*in.Reason) == "" { - return invalid("%s requires a reason", in.Type) - } - } - return nil -} - -func isNilID(id *uuid.UUID) bool { - return id == nil || *id == uuid.Nil -} - -func containsString(values []string, v string) bool { - for _, value := range values { - if value == v { - return true - } - } - return false -} - -func abs(v int64) int64 { - if v < 0 { - return -v - } - return v -} diff --git a/internal/processor/wallet_processor_db_test.go b/internal/processor/wallet_processor_db_test.go deleted file mode 100644 index d3aa795..0000000 --- a/internal/processor/wallet_processor_db_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package processor - -import ( - "context" - "os" - "testing" - "time" - - "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" -) - -// Runs the engine against Postgres, to show the rows it writes pass the database -// constraints and reconcile the way §7.5 requires. Needs TEST_DATABASE_URL pointing -// at a migrated database; see internal/repository/wallet_repository_test.go. -func TestWalletProcessor_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) - - org, a, b := uuid.New(), uuid.New(), uuid.New() - require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet test', 'basic')`, org).Error) - require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, a, org, b, org).Error) - customers := []uuid.UUID{a, b} - 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) - }) - - p := NewWalletProcessor(repository.NewWalletRepository(db)) - txm := repository.NewTxManager(db) - now := time.Now() - inTx := func(fn func(ctx context.Context) error) { - t.Helper() - require.NoError(t, txm.WithTransaction(context.Background(), fn)) - } - - // Without a transaction nothing moves. - _, err = p.Credit(context.Background(), earn(a, 10, nil)) - assert.ErrorIs(t, err, repository.ErrWalletTxRequired) - - var earned *WalletResult - inTx(func(ctx context.Context) error { - soon := now.Add(time.Hour) - earned, err = p.Credit(ctx, earn(a, 100, &soon)) - require.NoError(t, err) - _, err = p.Credit(ctx, earn(a, 50, nil)) - return err - }) - - // Transfer 120 from A to B, spanning both of A's lots. - inTx(func(ctx context.Context) error { - require.NoError(t, p.LockWallets(ctx, a, b)) - group, outID, inID := uuid.New(), uuid.New(), uuid.New() - out, err := p.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ - TransactionID: outID, CustomerID: a, Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypeTransferOut, Amount: 120, - ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: inID, - GroupID: &group, CounterpartyCustomerID: &b, Description: "Transfer ke B", - }}) - require.NoError(t, err) - _, err = p.Credit(ctx, WalletCreditInput{ - WalletEntry: WalletEntry{ - TransactionID: inID, CustomerID: b, Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypeTransferIn, Amount: 120, - ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: outID, - GroupID: &group, CounterpartyCustomerID: &a, Description: "Transfer dari A", - }, - Lots: out.CarryOver(), - }) - return err - }) - - // Reversing the 100 earned leaves A 30 short. The retry reads the shortfall back - // out of JSONB and takes nothing more. - rev := reversal(a, 100, earned) - rev.IdempotencyKey = "reverse:" + earned.Transaction.ID.String() - var first, second *WalletResult - inTx(func(ctx context.Context) error { - first, err = p.DebitUpTo(ctx, rev) - return err - }) - inTx(func(ctx context.Context) error { - second, err = p.DebitUpTo(ctx, rev) - return err - }) - assert.Equal(t, int64(-30), first.Transaction.Amount) - assert.Equal(t, int64(70), first.Shortfall) - assert.True(t, second.Replayed) - assert.Equal(t, int64(70), second.Shortfall) - assert.Equal(t, first.Transaction.ID, second.Transaction.ID) - - // Overdraw fails and rolls back cleanly. - err = txm.WithTransaction(context.Background(), func(ctx context.Context) error { - _, err := p.Debit(ctx, pay(b, 121)) - return err - }) - assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance) - - var balances []struct { - CustomerID uuid.UUID - PointBalance int64 - } - require.NoError(t, db.Raw(`SELECT customer_id, point_balance FROM customer_wallets WHERE customer_id IN ?`, customers).Scan(&balances).Error) - got := map[uuid.UUID]int64{} - for _, row := range balances { - got[row.CustomerID] = row.PointBalance - } - assert.Equal(t, map[uuid.UUID]int64{a: 0, b: 120}, got) - - // §7.5, straight from the tables. - var broken []string - require.NoError(t, db.Raw(` - SELECT 'wallet ' || w.customer_id 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.point_balance <> (SELECT COALESCE(SUM(remaining_amount), 0) FROM wallet_lots l WHERE l.customer_id = w.customer_id AND l.currency = 'POINT')) - UNION ALL - SELECT 'lot ' || l.id FROM wallet_lots l - WHERE l.customer_id IN ? AND l.original_amount - l.remaining_amount - <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_lot_allocations a WHERE a.lot_id = l.id) - UNION ALL - SELECT 'debit ' || t.id FROM wallet_transactions t - WHERE t.customer_id IN ? AND t.amount < 0 - AND -t.amount <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_lot_allocations a WHERE a.transaction_id = t.id)`, - customers, customers, customers).Scan(&broken).Error) - assert.Empty(t, broken) -} diff --git a/internal/processor/wallet_processor_test.go b/internal/processor/wallet_processor_test.go deleted file mode 100644 index 810dfd7..0000000 --- a/internal/processor/wallet_processor_test.go +++ /dev/null @@ -1,788 +0,0 @@ -package processor - -import ( - "context" - "errors" - "sort" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/repository" -) - -// walletRepoFake is an in-memory WalletRepository with the same conditional-update -// semantics as the real one. Locks are only counted: these tests are single-threaded, -// and the locking itself is covered by the repository tests against Postgres. -type walletRepoFake struct { - customers map[uuid.UUID]uuid.UUID // customer -> organization - wallets map[uuid.UUID]*entities.CustomerWallet - transactions []*entities.WalletTransaction - lots []*entities.WalletLot - allocations []entities.WalletLotAllocation - locks map[uuid.UUID]int - clock time.Time -} - -func newWalletRepoFake() *walletRepoFake { - return &walletRepoFake{ - customers: map[uuid.UUID]uuid.UUID{}, - wallets: map[uuid.UUID]*entities.CustomerWallet{}, - locks: map[uuid.UUID]int{}, - clock: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), - } -} - -func (f *walletRepoFake) tick() time.Time { - f.clock = f.clock.Add(time.Second) - return f.clock -} - -func (f *walletRepoFake) LockWallet(_ context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) { - org, ok := f.customers[customerID] - if !ok { - return nil, repository.ErrWalletNotFound - } - if f.wallets[customerID] == nil { - f.wallets[customerID] = &entities.CustomerWallet{CustomerID: customerID, OrganizationID: org} - } - f.locks[customerID]++ - w := *f.wallets[customerID] - return &w, nil -} - -func (f *walletRepoFake) LockWallets(ctx context.Context, a, b uuid.UUID) (*entities.CustomerWallet, *entities.CustomerWallet, error) { - wa, err := f.LockWallet(ctx, a) - if err != nil { - return nil, nil, err - } - wb, err := f.LockWallet(ctx, b) - return wa, wb, err -} - -func (f *walletRepoFake) AddBalance(_ context.Context, customerID uuid.UUID, currency string, delta int64) (int64, error) { - w := f.wallets[customerID] - if w == nil { - return 0, repository.ErrWalletNotFound - } - balance := &w.PointBalance - if currency == constants.WalletCurrencyCoin { - balance = &w.CoinBalance - } - if *balance+delta < 0 { - return 0, repository.ErrWalletInsufficientBalance - } - *balance += delta - return *balance, nil -} - -func (f *walletRepoFake) GetWallet(_ context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) { - w := f.wallets[customerID] - if w == nil { - return nil, errors.New("not found") - } - c := *w - return &c, nil -} - -func (f *walletRepoFake) CreateTransaction(_ context.Context, tx *entities.WalletTransaction) error { - if tx.IdempotencyKey != nil { - for _, t := range f.transactions { - if t.IdempotencyKey != nil && *t.IdempotencyKey == *tx.IdempotencyKey { - return errors.New("duplicate idempotency key") - } - } - } - if tx.ID == uuid.Nil { - tx.ID = uuid.New() - } - tx.CreatedAt = f.tick() - c := *tx - f.transactions = append(f.transactions, &c) - return nil -} - -func (f *walletRepoFake) GetTransactionByIdempotencyKey(_ context.Context, key string) (*entities.WalletTransaction, error) { - for _, t := range f.transactions { - if t.IdempotencyKey != nil && *t.IdempotencyKey == key { - c := *t - return &c, nil - } - } - return nil, nil -} - -func (f *walletRepoFake) CreateLot(_ context.Context, lot *entities.WalletLot) error { - if lot.ID == uuid.Nil { - lot.ID = uuid.New() - } - lot.CreatedAt = f.tick() - c := *lot - f.lots = append(f.lots, &c) - return nil -} - -func (f *walletRepoFake) GetLotsByIDs(_ context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) { - var out []entities.WalletLot - for _, lot := range f.lots { - for _, id := range ids { - if lot.ID == id { - out = append(out, *lot) - break - } - } - } - return out, nil -} - -func (f *walletRepoFake) ListLotsBySourceTransaction(_ context.Context, txID uuid.UUID) ([]entities.WalletLot, error) { - var out []entities.WalletLot - for _, lot := range f.lots { - if lot.SourceTransactionID == txID { - out = append(out, *lot) - } - } - return out, nil -} - -func (f *walletRepoFake) ListActiveLots(_ context.Context, customerID uuid.UUID, currency string, asOf time.Time) ([]entities.WalletLot, error) { - var out []entities.WalletLot - for _, lot := range f.lots { - if lot.CustomerID == customerID && lot.Currency == currency && lot.RemainingAmount > 0 && - (lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf)) { - out = append(out, *lot) - } - } - sort.SliceStable(out, func(i, j int) bool { - a, b := out[i], out[j] - switch { - case a.ExpiresAt == nil && b.ExpiresAt != nil: - return false - case a.ExpiresAt != nil && b.ExpiresAt == nil: - return true - case a.ExpiresAt != nil && !a.ExpiresAt.Equal(*b.ExpiresAt): - return a.ExpiresAt.Before(*b.ExpiresAt) - } - return a.CreatedAt.Before(b.CreatedAt) - }) - return out, nil -} - -func (f *walletRepoFake) ConsumeLot(_ context.Context, lotID uuid.UUID, amount int64) error { - for _, lot := range f.lots { - if lot.ID == lotID { - if lot.RemainingAmount < amount { - return repository.ErrWalletLotInsufficient - } - lot.RemainingAmount -= amount - return nil - } - } - return repository.ErrWalletLotInsufficient -} - -func (f *walletRepoFake) CreateAllocations(_ context.Context, allocations []entities.WalletLotAllocation) error { - f.allocations = append(f.allocations, allocations...) - return nil -} - -func (f *walletRepoFake) ListAllocationsByTransaction(_ context.Context, txID uuid.UUID) ([]entities.WalletLotAllocation, error) { - var out []entities.WalletLotAllocation - for _, a := range f.allocations { - if a.TransactionID == txID { - out = append(out, a) - } - } - return out, nil -} - -// assertInvariants checks the reconciliation rules of §7.5 over everything the fake -// holds. -func (f *walletRepoFake) assertInvariants(t *testing.T) { - t.Helper() - allocatedFromLot := map[uuid.UUID]int64{} - allocatedByTx := map[uuid.UUID]int64{} - for _, a := range f.allocations { - allocatedFromLot[a.LotID] += a.Amount - allocatedByTx[a.TransactionID] += a.Amount - } - createdByTx := map[uuid.UUID]int64{} - for _, lot := range f.lots { - createdByTx[lot.SourceTransactionID] += lot.OriginalAmount - assert.Equal(t, lot.OriginalAmount-allocatedFromLot[lot.ID], lot.RemainingAmount, "lot %s: original - allocations = remaining", lot.ID) - } - for _, tx := range f.transactions { - if tx.Amount > 0 { - assert.Equal(t, tx.Amount, createdByTx[tx.ID], "credit %s: lots add up to the amount", tx.Type) - assert.Zero(t, allocatedByTx[tx.ID], "credit %s has no allocations", tx.Type) - } else { - assert.Equal(t, -tx.Amount, allocatedByTx[tx.ID], "debit %s: allocations add up to the amount", tx.Type) - assert.Zero(t, createdByTx[tx.ID], "debit %s creates no lots", tx.Type) - } - } - for customerID, w := range f.wallets { - for currency, balance := range map[string]int64{ - constants.WalletCurrencyPoint: w.PointBalance, - constants.WalletCurrencyCoin: w.CoinBalance, - } { - var ledger, lots, last int64 - for _, tx := range f.transactions { - if tx.CustomerID == customerID && tx.Currency == currency { - ledger += tx.Amount - last = tx.BalanceAfter - } - } - for _, lot := range f.lots { - if lot.CustomerID == customerID && lot.Currency == currency { - lots += lot.RemainingAmount - } - } - assert.Equal(t, balance, ledger, "%s balance = SUM(ledger)", currency) - assert.Equal(t, balance, lots, "%s balance = SUM(lot remaining)", currency) - assert.Equal(t, balance, last, "%s balance = last balance_after", currency) - } - } -} - -type walletTestEnv struct { - repo *walletRepoFake - p *WalletProcessor - now time.Time - org uuid.UUID - ctx context.Context -} - -func newWalletTestEnv(t *testing.T) *walletTestEnv { - repo := newWalletRepoFake() - env := &walletTestEnv{ - repo: repo, - p: NewWalletProcessor(repo), - now: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), - org: uuid.New(), - ctx: context.Background(), - } - env.p.now = func() time.Time { return env.now } - t.Cleanup(func() { repo.assertInvariants(t) }) - return env -} - -func (e *walletTestEnv) customer() uuid.UUID { - id := uuid.New() - e.repo.customers[id] = e.org - return id -} - -func (e *walletTestEnv) at(d time.Duration) *time.Time { - v := e.now.Add(d) - return &v -} - -func ptr[T any](v T) *T { return &v } - -func earn(customerID uuid.UUID, amount int64, expiresAt *time.Time) WalletCreditInput { - return WalletCreditInput{ - WalletEntry: WalletEntry{ - CustomerID: customerID, - Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypeEarn, - Amount: amount, - ReferenceType: constants.WalletRefTypeOrder, - ReferenceID: uuid.New(), - OutletID: ptr(uuid.New()), - Description: "Belanja", - }, - Lots: []WalletLotInput{{Amount: amount, ExpiresAt: expiresAt}}, - } -} - -func pay(customerID uuid.UUID, amount int64) WalletDebitInput { - return WalletDebitInput{WalletEntry: WalletEntry{ - CustomerID: customerID, - Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypePayment, - Amount: amount, - ReferenceType: constants.WalletRefTypePayment, - ReferenceID: uuid.New(), - OutletID: ptr(uuid.New()), - Description: "Bayar", - }} -} - -func (e *walletTestEnv) credit(t *testing.T, in WalletCreditInput) *WalletResult { - t.Helper() - res, err := e.p.Credit(e.ctx, in) - require.NoError(t, err) - return res -} - -func (e *walletTestEnv) balance(t *testing.T, customerID uuid.UUID) int64 { - t.Helper() - w, err := e.repo.GetWallet(e.ctx, customerID) - require.NoError(t, err) - return w.PointBalance -} - -func allocationsOf(res *WalletResult) map[uuid.UUID]int64 { - out := map[uuid.UUID]int64{} - for _, a := range res.Allocations { - out[a.LotID] = a.Amount - } - return out -} - -func TestWalletProcessor_CreditCreatesLedgerRowAndLot(t *testing.T) { - e := newWalletTestEnv(t) - c := e.customer() - - res := e.credit(t, earn(c, 100, e.at(24*time.Hour))) - - assert.Equal(t, int64(100), res.Transaction.Amount) - assert.Equal(t, int64(100), res.Transaction.BalanceAfter) - assert.Equal(t, e.org, res.Transaction.OrganizationID, "organization comes from the wallet") - require.Len(t, res.Lots, 1) - assert.Equal(t, res.Transaction.ID, res.Lots[0].SourceTransactionID) - assert.Equal(t, e.at(24*time.Hour), res.Lots[0].ExpiresAt) - assert.Equal(t, int64(100), e.balance(t, c)) - assert.Equal(t, 1, e.repo.locks[c], "credit locks the wallet itself") -} - -func TestWalletProcessor_CreditWithoutLotsMakesOneNonExpiringLot(t *testing.T) { - e := newWalletTestEnv(t) - c := e.customer() - in := earn(c, 40, nil) - in.Lots = nil - - res := e.credit(t, in) - require.Len(t, res.Lots, 1) - assert.Equal(t, int64(40), res.Lots[0].OriginalAmount) - assert.Nil(t, res.Lots[0].ExpiresAt) -} - -func TestWalletProcessor_CreditRejectsLotsThatDoNotAddUp(t *testing.T) { - e := newWalletTestEnv(t) - c := e.customer() - in := earn(c, 100, nil) - in.Lots = []WalletLotInput{{Amount: 60}, {Amount: 30}} - - _, err := e.p.Credit(e.ctx, in) - assert.ErrorIs(t, err, ErrWalletInvalidEntry) - - in.Lots = []WalletLotInput{{Amount: 100}, {Amount: 0}} - _, err = e.p.Credit(e.ctx, in) - assert.ErrorIs(t, err, ErrWalletInvalidEntry) - assert.Empty(t, e.repo.transactions) -} - -func TestWalletProcessor_DebitAcrossSeveralLots(t *testing.T) { - e := newWalletTestEnv(t) - c := e.customer() - first := e.credit(t, earn(c, 30, e.at(1*time.Hour))).Lots[0] - second := e.credit(t, earn(c, 50, e.at(2*time.Hour))).Lots[0] - third := e.credit(t, earn(c, 40, e.at(3*time.Hour))).Lots[0] - - res, err := e.p.Debit(e.ctx, pay(c, 70)) - require.NoError(t, err) - - assert.Equal(t, int64(-70), res.Transaction.Amount) - assert.Equal(t, int64(50), res.Transaction.BalanceAfter) - assert.Equal(t, map[uuid.UUID]int64{first.ID: 30, second.ID: 40}, allocationsOf(res)) - assert.Equal(t, first.ID, res.Allocations[0].LotID, "allocations are reported in the order used") - assert.Equal(t, e.at(1*time.Hour), res.Allocations[0].ExpiresAt) - - lots, _ := e.repo.GetLotsByIDs(e.ctx, []uuid.UUID{first.ID, second.ID, third.ID}) - remaining := map[uuid.UUID]int64{} - for _, l := range lots { - remaining[l.ID] = l.RemainingAmount - } - assert.Equal(t, map[uuid.UUID]int64{first.ID: 0, second.ID: 10, third.ID: 40}, remaining) -} - -// K9: soonest expiry first, lots without an expiry last and oldest first among them, -// expired lots never. -func TestWalletProcessor_DebitFollowsLotOrder(t *testing.T) { - e := newWalletTestEnv(t) - c := e.customer() - neverOld := e.credit(t, earn(c, 10, nil)).Lots[0] - late := e.credit(t, earn(c, 10, e.at(48*time.Hour))).Lots[0] - soon := e.credit(t, earn(c, 10, e.at(1*time.Hour))).Lots[0] - neverNew := e.credit(t, earn(c, 10, nil)).Lots[0] - e.credit(t, earn(c, 10, e.at(-1*time.Hour))) // already expired - - var order []uuid.UUID - for i := 0; i < 4; i++ { - res, err := e.p.Debit(e.ctx, pay(c, 10)) - require.NoError(t, err) - require.Len(t, res.Allocations, 1) - order = append(order, res.Allocations[0].LotID) - } - assert.Equal(t, []uuid.UUID{soon.ID, late.ID, neverOld.ID, neverNew.ID}, order) - - // The expired lot still counts in the balance until the expiry job removes it, - // but it cannot be spent (§7.3). - assert.Equal(t, int64(10), e.balance(t, c)) - _, err := e.p.Debit(e.ctx, pay(c, 10)) - assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance) -} - -func TestWalletProcessor_DebitOverBalanceChangesNothing(t *testing.T) { - e := newWalletTestEnv(t) - c := e.customer() - e.credit(t, earn(c, 50, nil)) - - _, err := e.p.Debit(e.ctx, pay(c, 51)) - assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance) - assert.Equal(t, int64(50), e.balance(t, c)) - assert.Len(t, e.repo.transactions, 1) - assert.Empty(t, e.repo.allocations) - - // A customer who never had a wallet has nothing to spend. - _, err = e.p.Debit(e.ctx, pay(e.customer(), 1)) - assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance) -} - -func reversal(customerID uuid.UUID, amount int64, earnRes *WalletResult) WalletDebitInput { - in := WalletDebitInput{WalletEntry: WalletEntry{ - CustomerID: customerID, - Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypeEarnReversal, - Amount: amount, - ReferenceType: constants.WalletRefTypeOrder, - ReferenceID: earnRes.Transaction.ReferenceID, - ReversesTransactionID: &earnRes.Transaction.ID, - OutletID: earnRes.Transaction.OutletID, - Description: "Batal", - }} - for _, lot := range earnRes.Lots { - in.PreferredLotIDs = append(in.PreferredLotIDs, lot.ID) - } - return in -} - -func TestWalletProcessor_DebitUpToWithShortfall(t *testing.T) { - e := newWalletTestEnv(t) - c := e.customer() - earned := e.credit(t, earn(c, 100, nil)) - _, err := e.p.Debit(e.ctx, pay(c, 70)) - require.NoError(t, err) - - res, err := e.p.DebitUpTo(e.ctx, reversal(c, 100, earned)) - require.NoError(t, err) - assert.Equal(t, int64(-30), res.Transaction.Amount) - assert.Equal(t, int64(70), res.Shortfall) - assert.Equal(t, int64(100), res.Transaction.Metadata["requested_amount"]) - assert.Equal(t, int64(70), res.Transaction.Metadata["shortfall"]) - assert.Equal(t, int64(0), e.balance(t, c)) - - // Nothing left: no ledger row, the whole amount is shortfall. - in := reversal(c, 5, earned) - res, err = e.p.DebitUpTo(e.ctx, in) - require.NoError(t, err) - assert.Nil(t, res.Transaction) - assert.Equal(t, int64(5), res.Shortfall) - assert.Len(t, e.repo.transactions, 3) -} - -// A reversal draws from the lots its EARN created first (F10), even when an older lot -// would come first in K9 order, and even when that lot has expired. -func TestWalletProcessor_DebitDrawsPreferredLotsFirst(t *testing.T) { - e := newWalletTestEnv(t) - c := e.customer() - older := e.credit(t, earn(c, 50, e.at(1*time.Hour))).Lots[0] - earned := e.credit(t, earn(c, 20, e.at(-1*time.Hour))) - - res, err := e.p.Debit(e.ctx, reversal(c, 30, earned)) - require.NoError(t, err) - require.Len(t, res.Allocations, 2) - assert.Equal(t, earned.Lots[0].ID, res.Allocations[0].LotID) - assert.Equal(t, int64(20), res.Allocations[0].Amount) - assert.Equal(t, older.ID, res.Allocations[1].LotID) - assert.Equal(t, int64(10), res.Allocations[1].Amount) -} - -func TestWalletProcessor_DebitRejectsSomeoneElsesLot(t *testing.T) { - e := newWalletTestEnv(t) - a, b := e.customer(), e.customer() - e.credit(t, earn(a, 10, nil)) - other := e.credit(t, earn(b, 10, nil)) - - in := pay(a, 5) - in.PreferredLotIDs = []uuid.UUID{other.Lots[0].ID} - _, err := e.p.Debit(e.ctx, in) - assert.ErrorIs(t, err, ErrWalletInvalidEntry) - - in.PreferredLotIDs = []uuid.UUID{uuid.New()} - _, err = e.p.Debit(e.ctx, in) - assert.ErrorIs(t, err, ErrWalletInvalidEntry) -} - -func TestWalletProcessor_ExpireDrawsTheExpiredLot(t *testing.T) { - e := newWalletTestEnv(t) - c := e.customer() - e.credit(t, earn(c, 10, nil)) - expired := e.credit(t, earn(c, 25, e.at(-1*time.Hour))).Lots[0] - - res, err := e.p.Debit(e.ctx, WalletDebitInput{ - WalletEntry: WalletEntry{ - CustomerID: c, - Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypeExpire, - Amount: expired.RemainingAmount, - ReferenceType: constants.WalletRefTypeLot, - ReferenceID: expired.ID, - Description: "Kedaluwarsa", - IdempotencyKey: "expire:" + expired.ID.String(), - }, - PreferredLotIDs: []uuid.UUID{expired.ID}, - }) - require.NoError(t, err) - assert.Equal(t, map[uuid.UUID]int64{expired.ID: 25}, allocationsOf(res)) - assert.Equal(t, int64(10), e.balance(t, c)) -} - -func TestWalletProcessor_IdempotentCredit(t *testing.T) { - e := newWalletTestEnv(t) - c := e.customer() - in := earn(c, 100, nil) - in.IdempotencyKey = "earn:order-1" - - first := e.credit(t, in) - in.ReferenceID = first.Transaction.ReferenceID - second := e.credit(t, in) - - assert.True(t, second.Replayed) - assert.False(t, first.Replayed) - assert.Equal(t, first.Transaction.ID, second.Transaction.ID) - assert.Equal(t, first.Lots[0].ID, second.Lots[0].ID) - assert.Equal(t, int64(100), e.balance(t, c)) - assert.Len(t, e.repo.transactions, 1) -} - -func TestWalletProcessor_IdempotentDebit(t *testing.T) { - e := newWalletTestEnv(t) - c := e.customer() - e.credit(t, earn(c, 30, e.at(time.Hour))) - e.credit(t, earn(c, 30, nil)) - in := pay(c, 40) - in.IdempotencyKey = "pay:1" - - first, err := e.p.Debit(e.ctx, in) - require.NoError(t, err) - second, err := e.p.Debit(e.ctx, in) - require.NoError(t, err) - - assert.True(t, second.Replayed) - assert.Equal(t, first.Transaction.ID, second.Transaction.ID) - assert.ElementsMatch(t, first.Allocations, second.Allocations) - assert.Equal(t, int64(20), e.balance(t, c)) -} - -func TestWalletProcessor_IdempotentDebitUpToKeepsShortfall(t *testing.T) { - e := newWalletTestEnv(t) - c := e.customer() - earned := e.credit(t, earn(c, 100, nil)) - _, err := e.p.Debit(e.ctx, pay(c, 60)) - require.NoError(t, err) - in := reversal(c, 100, earned) - in.IdempotencyKey = "reverse:order-1" - - first, err := e.p.DebitUpTo(e.ctx, in) - require.NoError(t, err) - e.credit(t, earn(c, 500, nil)) // new balance must not be taken by the retry - second, err := e.p.DebitUpTo(e.ctx, in) - require.NoError(t, err) - - assert.True(t, second.Replayed) - assert.Equal(t, first.Transaction.ID, second.Transaction.ID) - assert.Equal(t, int64(60), second.Shortfall) - assert.Equal(t, int64(500), e.balance(t, c)) -} - -func TestWalletProcessor_IdempotencyKeyReusedForAnotherOperation(t *testing.T) { - e := newWalletTestEnv(t) - c := e.customer() - in := earn(c, 100, nil) - in.IdempotencyKey = "k" - e.credit(t, in) - - other := earn(c, 99, nil) - other.IdempotencyKey = "k" - _, err := e.p.Credit(e.ctx, other) - assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) - - debit := pay(c, 100) - debit.IdempotencyKey = "k" - _, err = e.p.Debit(e.ctx, debit) - assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) - - otherCustomer := earn(e.customer(), 100, nil) - otherCustomer.IdempotencyKey = "k" - _, err = e.p.Credit(e.ctx, otherCustomer) - assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) -} - -// A transfer debits the sender and credits the receiver with lots that keep the -// sender's expiry (K9), following the example in §8. -func TestWalletProcessor_TransferCarriesExpiry(t *testing.T) { - e := newWalletTestEnv(t) - a, b := e.customer(), e.customer() - dec := e.credit(t, earn(a, 100, e.at(30*24*time.Hour))).Lots[0] - jan := e.credit(t, earn(a, 50, e.at(60*24*time.Hour))).Lots[0] - - require.NoError(t, e.p.LockWallets(e.ctx, a, b)) - group, outID, inID := uuid.New(), uuid.New(), uuid.New() - out, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: WalletEntry{ - TransactionID: outID, CustomerID: a, Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypeTransferOut, Amount: 120, - ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: inID, - GroupID: &group, CounterpartyCustomerID: &b, Description: "Transfer ke B", - }}) - require.NoError(t, err) - received, err := e.p.Credit(e.ctx, WalletCreditInput{ - WalletEntry: WalletEntry{ - TransactionID: inID, CustomerID: b, Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypeTransferIn, Amount: 120, - ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: outID, - GroupID: &group, CounterpartyCustomerID: &a, Description: "Transfer dari A", - }, - Lots: out.CarryOver(), - }) - require.NoError(t, err) - - assert.Equal(t, outID, out.Transaction.ID) - assert.Equal(t, inID, received.Transaction.ID) - require.Len(t, received.Lots, 2) - assert.Equal(t, int64(100), received.Lots[0].OriginalAmount) - assert.Equal(t, dec.ExpiresAt, received.Lots[0].ExpiresAt) - assert.Equal(t, &dec.ID, received.Lots[0].OriginLotID) - assert.Equal(t, int64(20), received.Lots[1].OriginalAmount) - assert.Equal(t, jan.ExpiresAt, received.Lots[1].ExpiresAt) - assert.Equal(t, &jan.ID, received.Lots[1].OriginLotID) - assert.Equal(t, int64(30), e.balance(t, a)) - assert.Equal(t, int64(120), e.balance(t, b)) -} - -func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) { - c := uuid.New() - outlet := ptr(uuid.New()) - - credits := map[string]func(*WalletCreditInput){ - "unknown type": func(in *WalletCreditInput) { in.Type = "BONUS" }, - "debit-only type as credit": func(in *WalletCreditInput) { - in.Type = constants.WalletTxTypePayment - in.ReferenceType = constants.WalletRefTypePayment - }, - "unknown currency": func(in *WalletCreditInput) { in.Currency = "GOLD" }, - "zero amount": func(in *WalletCreditInput) { in.Amount = 0; in.Lots = nil }, - "negative amount": func(in *WalletCreditInput) { in.Amount = -5; in.Lots = nil }, - "wrong reference type": func(in *WalletCreditInput) { in.ReferenceType = constants.WalletRefTypeUser }, - "missing reference id": func(in *WalletCreditInput) { in.ReferenceID = uuid.Nil }, - "missing description": func(in *WalletCreditInput) { in.Description = " " }, - "EARN without outlet": func(in *WalletCreditInput) { in.OutletID = nil }, - "missing customer": func(in *WalletCreditInput) { in.CustomerID = uuid.Nil }, - "EXCHANGE_IN in COIN": func(in *WalletCreditInput) { - in.Type = constants.WalletTxTypeExchangeIn - in.Currency = constants.WalletCurrencyCoin - in.ReferenceType = constants.WalletRefTypeWalletTx - in.GroupID = ptr(uuid.New()) - }, - "TRANSFER_IN to self": func(in *WalletCreditInput) { - in.Type = constants.WalletTxTypeTransferIn - in.ReferenceType = constants.WalletRefTypeWalletTx - in.GroupID = ptr(uuid.New()) - in.CounterpartyCustomerID = &in.CustomerID - }, - "TRANSFER_IN without group": func(in *WalletCreditInput) { - in.Type = constants.WalletTxTypeTransferIn - in.ReferenceType = constants.WalletRefTypeWalletTx - in.CounterpartyCustomerID = ptr(uuid.New()) - }, - "PAYMENT_REFUND without source": func(in *WalletCreditInput) { - in.Type = constants.WalletTxTypePaymentRefund - in.ReferenceType = constants.WalletRefTypePayment - }, - "ADJUSTMENT without reason": func(in *WalletCreditInput) { - in.Type = constants.WalletTxTypeAdjustment - in.ReferenceType = constants.WalletRefTypeUser - in.CreatedByUser = ptr(uuid.New()) - }, - "ADJUSTMENT blank reason": func(in *WalletCreditInput) { - in.Type = constants.WalletTxTypeAdjustment - in.ReferenceType = constants.WalletRefTypeUser - in.CreatedByUser = ptr(uuid.New()) - in.Reason = ptr(" ") - }, - "ADJUSTMENT without admin": func(in *WalletCreditInput) { - in.Type = constants.WalletTxTypeAdjustment - in.ReferenceType = constants.WalletRefTypeUser - in.Reason = ptr("komplain") - }, - "MIGRATION wrong reference": func(in *WalletCreditInput) { - in.Type = constants.WalletTxTypeMigration - in.ReferenceType = constants.WalletRefTypeOrder - }, - } - for name, mutate := range credits { - t.Run("credit/"+name, func(t *testing.T) { - e := newWalletTestEnv(t) - e.repo.customers[c] = e.org - in := earn(c, 10, nil) - in.OutletID = outlet - mutate(&in) - _, err := e.p.Credit(e.ctx, in) - assert.ErrorIs(t, err, ErrWalletInvalidEntry) - assert.Empty(t, e.repo.transactions) - }) - } - - debits := map[string]func(*WalletDebitInput){ - "credit-only type as debit": func(in *WalletDebitInput) { - in.Type = constants.WalletTxTypeMigration - in.ReferenceType = constants.WalletRefTypeLegacyPoints - }, - "PAYMENT in COIN": func(in *WalletDebitInput) { in.Currency = constants.WalletCurrencyCoin }, - "GAME_SPEND in POINT": func(in *WalletDebitInput) { - in.Type = constants.WalletTxTypeGameSpend - in.ReferenceType = constants.WalletRefTypeGamePlay - }, - "EXPIRE not pointing at a lot": func(in *WalletDebitInput) { - in.Type = constants.WalletTxTypeExpire - in.ReferenceType = constants.WalletRefTypeOrder - }, - "EARN_REVERSAL without source": func(in *WalletDebitInput) { - in.Type = constants.WalletTxTypeEarnReversal - in.ReferenceType = constants.WalletRefTypeOrder - }, - "TRANSFER_OUT without counterparty": func(in *WalletDebitInput) { - in.Type = constants.WalletTxTypeTransferOut - in.ReferenceType = constants.WalletRefTypeWalletTx - in.GroupID = ptr(uuid.New()) - }, - "REWARD_REDEEM wrong reference": func(in *WalletDebitInput) { - in.Type = constants.WalletTxTypeRewardRedeem - in.ReferenceType = constants.WalletRefTypeOrder - }, - } - for name, mutate := range debits { - t.Run("debit/"+name, func(t *testing.T) { - e := newWalletTestEnv(t) - e.repo.customers[c] = e.org - e.credit(t, earn(c, 100, nil)) - in := pay(c, 10) - mutate(&in) - _, err := e.p.Debit(e.ctx, in) - assert.ErrorIs(t, err, ErrWalletInvalidEntry) - assert.Len(t, e.repo.transactions, 1) - }) - } -} - -func TestWalletProcessor_UnknownCustomer(t *testing.T) { - e := newWalletTestEnv(t) - _, err := e.p.Credit(e.ctx, earn(uuid.New(), 10, nil)) - assert.ErrorIs(t, err, repository.ErrWalletNotFound) -} diff --git a/internal/processor/wallet_query_processor.go b/internal/processor/wallet_query_processor.go deleted file mode 100644 index 3a83fb6..0000000 --- a/internal/processor/wallet_query_processor.go +++ /dev/null @@ -1,336 +0,0 @@ -package processor - -import ( - "context" - "errors" - "fmt" - "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" -) - -// ErrInvalidWalletQuery wraps every rejection of a malformed history query, so the -// handler can answer with a validation error rather than a server error. -var ErrInvalidWalletQuery = errors.New("invalid wallet query") - -const ( - walletRecentTransactions = 5 - walletLegacyHistorySize = 10 - walletTransactionsPageLimit = 20 - walletTransactionsMaxLimit = 100 -) - -// walletDisplayLocation is where the customer's calendar days start and end, for -// date filters. Same zone as the database connection and the expiry grouping. -var walletDisplayLocation = func() *time.Location { - loc, err := time.LoadLocation("Asia/Jakarta") - if err != nil { - return time.FixedZone("WIB", 7*60*60) - } - return loc -}() - -// WalletQueryProcessor answers what the customer app shows about a wallet: balances, -// history and upcoming expiry (docs/prd-point-coin.md F6). It only reads. -type WalletQueryProcessor struct { - repo repository.WalletQueryRepository - settings pointValueReader - now func() time.Time -} - -// pointValueReader gives the rupiah value of one EnakPoint in an organization. -// LoyaltySettingsProcessor is one. -type pointValueReader interface { - PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) -} - -func NewWalletQueryProcessor(repo repository.WalletQueryRepository, settings pointValueReader) *WalletQueryProcessor { - return &WalletQueryProcessor{repo: repo, settings: settings, now: time.Now} -} - -// Summary is GET /customer/wallet. A customer who has never had a wallet gets zeros. -func (p *WalletQueryProcessor) Summary(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerWalletResponseData, error) { - organizationID, err := p.repo.GetCustomerOrganizationID(ctx, customerID) - if err != nil { - return nil, err - } - now := p.now() - - balances, err := p.repo.SpendableBalances(ctx, customerID, now) - if err != nil { - return nil, err - } - pointValue, err := p.settings.PointValue(ctx, organizationID) - if err != nil { - return nil, err - } - expiring, err := p.repo.NearestExpiring(ctx, customerID, now) - if err != nil { - return nil, err - } - recent, err := p.recent(ctx, customerID, "", walletRecentTransactions) - if err != nil { - return nil, err - } - points, err := p.legacyPoints(ctx, customerID, balances) - if err != nil { - return nil, err - } - tokens, err := p.legacyTokens(ctx, customerID, balances) - if err != nil { - return nil, err - } - - data := &models.GetCustomerWalletResponseData{ - PointBalance: balances[constants.WalletCurrencyPoint], - CoinBalance: balances[constants.WalletCurrencyCoin], - PointValue: pointValue, - PointDiscountValue: balances[constants.WalletCurrencyPoint] * pointValue, - RecentTransactions: recent, - TotalPoints: points.TotalPoints, - TotalTokens: tokens.TotalTokens, - PointsHistory: points.PointsHistory, - TokensHistory: tokens.TokensHistory, - LastUpdated: points.LastUpdated, - } - if tokens.LastUpdated.After(data.LastUpdated) { - data.LastUpdated = tokens.LastUpdated - } - for _, e := range expiring { - entry := &models.CustomerWalletExpiring{Amount: e.Amount, Date: e.Date} - switch e.Currency { - case constants.WalletCurrencyPoint: - data.NearestExpiring.Point = entry - case constants.WalletCurrencyCoin: - data.NearestExpiring.Coin = entry - } - } - return data, nil -} - -// Transactions is GET /customer/wallet/transactions. -func (p *WalletQueryProcessor) Transactions(ctx context.Context, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) { - filter, page, err := walletTransactionFilter(customerID, query) - if err != nil { - return nil, err - } - rows, total, err := p.repo.ListTransactions(ctx, filter) - if err != nil { - return nil, err - } - items, err := p.toTransactions(ctx, rows) - if err != nil { - return nil, err - } - totalPages := int((total + int64(filter.Limit) - 1) / int64(filter.Limit)) - return &models.PaginatedResponse[models.CustomerWalletTransaction]{ - Data: items, - Pagination: models.Pagination{Page: page, Limit: filter.Limit, Total: total, TotalPages: totalPages}, - }, nil -} - -// LegacyPoints is the pre-wallet GET /customer/points, now read from the wallet. -func (p *WalletQueryProcessor) LegacyPoints(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerPointsResponseData, error) { - balances, err := p.repo.SpendableBalances(ctx, customerID, p.now()) - if err != nil { - return nil, err - } - return p.legacyPoints(ctx, customerID, balances) -} - -func (p *WalletQueryProcessor) legacyPoints(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerPointsResponseData, error) { - recent, err := p.recent(ctx, customerID, constants.WalletCurrencyPoint, walletLegacyHistorySize) - if err != nil { - return nil, err - } - data := &models.GetCustomerPointsResponseData{TotalPoints: balances[constants.WalletCurrencyPoint]} - for _, tx := range recent { - data.PointsHistory = append(data.PointsHistory, models.PointsHistoryItem{ - ID: tx.ID.String(), Points: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt, - }) - } - if len(recent) > 0 { - data.LastUpdated = recent[0].CreatedAt - } - return data, nil -} - -// LegacyTokens is the pre-wallet GET /customer/tokens. Tokens are EnakCoin now. -func (p *WalletQueryProcessor) LegacyTokens(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerTokensResponseData, error) { - balances, err := p.repo.SpendableBalances(ctx, customerID, p.now()) - if err != nil { - return nil, err - } - return p.legacyTokens(ctx, customerID, balances) -} - -func (p *WalletQueryProcessor) legacyTokens(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerTokensResponseData, error) { - recent, err := p.recent(ctx, customerID, constants.WalletCurrencyCoin, walletLegacyHistorySize) - if err != nil { - return nil, err - } - data := &models.GetCustomerTokensResponseData{TotalTokens: balances[constants.WalletCurrencyCoin]} - for _, tx := range recent { - data.TokensHistory = append(data.TokensHistory, models.TokensHistoryItem{ - ID: tx.ID.String(), Tokens: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt, - }) - } - if len(recent) > 0 { - data.LastUpdated = recent[0].CreatedAt - } - return data, nil -} - -func (p *WalletQueryProcessor) recent(ctx context.Context, customerID uuid.UUID, currency string, limit int) ([]models.CustomerWalletTransaction, error) { - rows, _, err := p.repo.ListTransactions(ctx, repository.WalletTransactionFilter{ - CustomerID: customerID, Currency: currency, Limit: limit, - }) - if err != nil { - return nil, err - } - return p.toTransactions(ctx, rows) -} - -func (p *WalletQueryProcessor) toTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) { - return buildWalletTransactions(ctx, p.repo, rows) -} - -// buildWalletTransactions turns ledger rows into what the apps show, in the same -// order: where each row came from or went to, and the lots each credit created. -func buildWalletTransactions(ctx context.Context, repo repository.WalletQueryRepository, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) { - var credits []uuid.UUID - for _, row := range rows { - if row.Amount > 0 { - credits = append(credits, row.ID) - } - } - lots, err := repo.ListLotsBySourceTransactions(ctx, credits) - if err != nil { - return nil, err - } - lotsByTx := make(map[uuid.UUID][]entities.WalletLot, len(credits)) - for _, lot := range lots { - lotsByTx[lot.SourceTransactionID] = append(lotsByTx[lot.SourceTransactionID], lot) - } - - items := make([]models.CustomerWalletTransaction, 0, len(rows)) - for _, row := range rows { - ref := &models.CustomerWalletTransactionRef{Type: row.ReferenceType, ID: row.ReferenceID} - item := models.CustomerWalletTransaction{ - ID: row.ID, - Currency: row.Currency, - Type: row.Type, - Amount: row.Amount, - BalanceAfter: row.BalanceAfter, - Description: row.Description, - OutletID: row.OutletID, - ReversesTransactionID: row.ReversesTransactionID, - GroupID: row.GroupID, - CreatedAt: row.CreatedAt, - } - // The counterparty's id, the admin and the metadata are left out on purpose: - // the customer sees the masked name in the description (§8.1). - if row.Amount > 0 { - item.Source = ref - for _, lot := range lotsByTx[row.ID] { - item.Lots = append(item.Lots, models.CustomerWalletLot{ - Amount: lot.OriginalAmount, Remaining: lot.RemainingAmount, ExpiresAt: lot.ExpiresAt, - }) - if lot.ExpiresAt != nil && (item.ExpiresAt == nil || lot.ExpiresAt.Before(*item.ExpiresAt)) { - item.ExpiresAt = lot.ExpiresAt - } - } - } else { - item.Destination = ref - } - items = append(items, item) - } - return items, nil -} - -func walletTransactionFilter(customerID uuid.UUID, q models.ListCustomerWalletTransactionsQuery) (repository.WalletTransactionFilter, int, error) { - invalid := func(format string, args ...any) error { - return fmt.Errorf("%w: %s", ErrInvalidWalletQuery, fmt.Sprintf(format, args...)) - } - filter := repository.WalletTransactionFilter{CustomerID: customerID} - - page := q.Page - if page == 0 { - page = 1 - } - if page < 1 { - return filter, 0, invalid("page must be at least 1") - } - filter.Limit = q.Limit - if filter.Limit == 0 { - filter.Limit = walletTransactionsPageLimit - } - if filter.Limit < 1 || filter.Limit > walletTransactionsMaxLimit { - return filter, 0, invalid("limit must be between 1 and %d", walletTransactionsMaxLimit) - } - filter.Offset = (page - 1) * filter.Limit - - if q.Currency != "" { - currency := strings.ToUpper(q.Currency) - if !constants.IsValidWalletCurrency(currency) { - return filter, 0, invalid("currency must be POINT or COIN") - } - filter.Currency = currency - } - for _, t := range strings.Split(q.Type, ",") { - t = strings.ToUpper(strings.TrimSpace(t)) - if t == "" { - continue - } - if _, ok := walletTypeRules[t]; !ok { - return filter, 0, invalid("unknown type %q", t) - } - filter.Types = append(filter.Types, t) - } - - if q.From != "" { - from, err := time.ParseInLocation("2006-01-02", q.From, walletDisplayLocation) - if err != nil { - return filter, 0, invalid("from must be a date like 2026-01-31") - } - filter.From = &from - } - if q.To != "" { - to, err := time.ParseInLocation("2006-01-02", q.To, walletDisplayLocation) - if err != nil { - return filter, 0, invalid("to must be a date like 2026-01-31") - } - // To is inclusive for the customer, exclusive in the query. - end := to.AddDate(0, 0, 1) - filter.To = &end - } - if filter.From != nil && filter.To != nil && !filter.From.Before(*filter.To) { - return filter, 0, invalid("from must not be after to") - } - return filter, page, nil -} - -// Expiring is GET /customer/wallet/expiring: what will expire, grouped by day (F6). -func (p *WalletQueryProcessor) Expiring(ctx context.Context, customerID uuid.UUID) (*models.CustomerWalletExpiringList, error) { - rows, err := p.repo.ExpiringByDay(ctx, customerID, p.now()) - if err != nil { - return nil, err - } - list := &models.CustomerWalletExpiringList{Point: []models.CustomerWalletExpiring{}, Coin: []models.CustomerWalletExpiring{}} - for _, row := range rows { - item := models.CustomerWalletExpiring{Amount: row.Amount, Date: row.Date} - switch row.Currency { - case constants.WalletCurrencyPoint: - list.Point = append(list.Point, item) - case constants.WalletCurrencyCoin: - list.Coin = append(list.Coin, item) - } - } - return list, nil -} diff --git a/internal/processor/wallet_query_processor_test.go b/internal/processor/wallet_query_processor_test.go deleted file mode 100644 index bf6def7..0000000 --- a/internal/processor/wallet_query_processor_test.go +++ /dev/null @@ -1,266 +0,0 @@ -package processor - -import ( - "context" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -type walletQueryRepoFake struct { - org uuid.UUID - spendable map[string]int64 - expiring []repository.WalletExpiringAmount - transactions []entities.WalletTransaction - total int64 - lots []entities.WalletLot - filters []repository.WalletTransactionFilter -} - -func (f *walletQueryRepoFake) GetCustomerOrganizationID(context.Context, uuid.UUID) (uuid.UUID, error) { - if f.org == uuid.Nil { - return uuid.Nil, repository.ErrWalletNotFound - } - return f.org, nil -} - -func (f *walletQueryRepoFake) SpendableBalances(context.Context, uuid.UUID, time.Time) (map[string]int64, error) { - return f.spendable, nil -} - -func (f *walletQueryRepoFake) NearestExpiring(context.Context, uuid.UUID, time.Time) ([]repository.WalletExpiringAmount, error) { - return f.expiring, nil -} - -func (f *walletQueryRepoFake) ListTransactions(_ context.Context, filter repository.WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) { - f.filters = append(f.filters, filter) - var out []entities.WalletTransaction - for _, tx := range f.transactions { - if filter.Currency == "" || tx.Currency == filter.Currency { - out = append(out, tx) - } - } - if len(out) > filter.Limit { - out = out[:filter.Limit] - } - return out, f.total, nil -} - -func (f *walletQueryRepoFake) ListLotsBySourceTransactions(_ context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) { - var out []entities.WalletLot - for _, lot := range f.lots { - for _, id := range ids { - if lot.SourceTransactionID == id { - out = append(out, lot) - } - } - } - return out, nil -} - -type orgSettingsFake map[string]string - -func newWalletQueryTest(repo *walletQueryRepoFake, settings orgSettingsFake) *WalletQueryProcessor { - p := NewWalletQueryProcessor(repo, NewLoyaltySettingsProcessor(&loyaltyRepoFake{orgValues: settings}, txRunnerFake{})) - p.now = func() time.Time { return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) } - return p -} - -func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.T) { - order, payment := uuid.New(), uuid.New() - earnID, payID := uuid.New(), uuid.New() - soon := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) - later := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) - created := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) - repo := &walletQueryRepoFake{ - org: uuid.New(), - spendable: map[string]int64{constants.WalletCurrencyPoint: 250, constants.WalletCurrencyCoin: 8}, - expiring: []repository.WalletExpiringAmount{ - {Currency: constants.WalletCurrencyPoint, Date: "2026-07-01", Amount: 100}, - }, - transactions: []entities.WalletTransaction{ - {ID: payID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, Amount: -50, BalanceAfter: 250, - ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, Description: "Bayar #ORD-1", CreatedAt: created.Add(time.Hour), - CounterpartyCustomerID: ptr(uuid.New()), Metadata: entities.Metadata{"point_value": 100}}, - {ID: earnID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn, Amount: 300, BalanceAfter: 300, - ReferenceType: constants.WalletRefTypeOrder, ReferenceID: order, Description: "Belanja #ORD-1", CreatedAt: created}, - }, - lots: []entities.WalletLot{ - {SourceTransactionID: earnID, OriginalAmount: 200, RemainingAmount: 150, ExpiresAt: &later}, - {SourceTransactionID: earnID, OriginalAmount: 100, RemainingAmount: 100, ExpiresAt: &soon}, - }, - } - p := newWalletQueryTest(repo, orgSettingsFake{constants.LoyaltyPointValueKey: "100"}) - - data, err := p.Summary(context.Background(), uuid.New()) - require.NoError(t, err) - - assert.Equal(t, int64(250), data.PointBalance) - assert.Equal(t, int64(8), data.CoinBalance) - assert.Equal(t, int64(100), data.PointValue) - assert.Equal(t, int64(25000), data.PointDiscountValue) - assert.Equal(t, &models.CustomerWalletExpiring{Amount: 100, Date: "2026-07-01"}, data.NearestExpiring.Point) - assert.Nil(t, data.NearestExpiring.Coin) - - require.Len(t, data.RecentTransactions, 2) - pay, earn := data.RecentTransactions[0], data.RecentTransactions[1] - assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypePayment, ID: payment}, pay.Destination) - assert.Nil(t, pay.Source) - assert.Empty(t, pay.Lots) - assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypeOrder, ID: order}, earn.Source) - assert.Nil(t, earn.Destination) - assert.Equal(t, &soon, earn.ExpiresAt, "earliest expiry among the lots the credit created") - assert.Len(t, earn.Lots, 2) - - // The pre-wallet fields are still filled, from the wallet. - assert.Equal(t, int64(250), data.TotalPoints) - assert.Equal(t, int64(8), data.TotalTokens) - require.Len(t, data.PointsHistory, 2) - assert.Equal(t, int64(-50), data.PointsHistory[0].Points) - assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type) - assert.Equal(t, created.Add(time.Hour), data.LastUpdated) -} - -func TestWalletQueryProcessor_PointValueFallsBackToDefault(t *testing.T) { - for name, settings := range map[string]orgSettingsFake{ - "not set": {}, - "garbage": {constants.LoyaltyPointValueKey: "abc"}, - "zero": {constants.LoyaltyPointValueKey: "0"}, - "negative": {constants.LoyaltyPointValueKey: "-5"}, - } { - t.Run(name, func(t *testing.T) { - repo := &walletQueryRepoFake{org: uuid.New(), spendable: map[string]int64{constants.WalletCurrencyPoint: 40}} - data, err := newWalletQueryTest(repo, settings).Summary(context.Background(), uuid.New()) - require.NoError(t, err) - assert.Equal(t, constants.LoyaltyPointValueDefault, data.PointValue) - assert.Equal(t, int64(40), data.PointDiscountValue) - }) - } -} - -func TestWalletQueryProcessor_SummaryForCustomerWithoutWallet(t *testing.T) { - repo := &walletQueryRepoFake{org: uuid.New(), spendable: map[string]int64{}} - data, err := newWalletQueryTest(repo, orgSettingsFake{}).Summary(context.Background(), uuid.New()) - require.NoError(t, err) - assert.Zero(t, data.PointBalance) - assert.Zero(t, data.CoinBalance) - assert.NotNil(t, data.RecentTransactions, "an empty list, not null, for the app") - - _, err = newWalletQueryTest(&walletQueryRepoFake{}, orgSettingsFake{}).Summary(context.Background(), uuid.New()) - assert.ErrorIs(t, err, repository.ErrWalletNotFound) -} - -func TestWalletQueryProcessor_LegacyEndpointsSplitByCurrency(t *testing.T) { - repo := &walletQueryRepoFake{ - org: uuid.New(), - spendable: map[string]int64{constants.WalletCurrencyPoint: 100, constants.WalletCurrencyCoin: 8}, - transactions: []entities.WalletTransaction{ - {ID: uuid.New(), Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration, Amount: 8}, - {ID: uuid.New(), Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: 100}, - }, - } - p := newWalletQueryTest(repo, orgSettingsFake{}) - - points, err := p.LegacyPoints(context.Background(), uuid.New()) - require.NoError(t, err) - assert.Equal(t, int64(100), points.TotalPoints) - require.Len(t, points.PointsHistory, 1) - assert.Equal(t, int64(100), points.PointsHistory[0].Points) - - tokens, err := p.LegacyTokens(context.Background(), uuid.New()) - require.NoError(t, err) - assert.Equal(t, int64(8), tokens.TotalTokens) - require.Len(t, tokens.TokensHistory, 1) - assert.Equal(t, int64(8), tokens.TokensHistory[0].Tokens) -} - -func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) { - customer := uuid.New() - jakarta := walletDisplayLocation - - repo := &walletQueryRepoFake{org: uuid.New(), total: 45} - page, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{ - Page: 3, Limit: 10, Currency: "point", Type: "earn, PAYMENT", From: "2026-05-01", To: "2026-05-31", - }) - require.NoError(t, err) - assert.Equal(t, models.Pagination{Page: 3, Limit: 10, Total: 45, TotalPages: 5}, page.Pagination) - assert.NotNil(t, page.Data) - - f := repo.filters[0] - assert.Equal(t, customer, f.CustomerID) - assert.Equal(t, 20, f.Offset) - assert.Equal(t, 10, f.Limit) - assert.Equal(t, constants.WalletCurrencyPoint, f.Currency) - assert.Equal(t, []string{constants.WalletTxTypeEarn, constants.WalletTxTypePayment}, f.Types) - assert.True(t, f.From.Equal(time.Date(2026, 5, 1, 0, 0, 0, 0, jakarta))) - assert.True(t, f.To.Equal(time.Date(2026, 6, 1, 0, 0, 0, 0, jakarta)), "to covers the whole last day") - - // Defaults. - repo = &walletQueryRepoFake{org: uuid.New()} - page, err = newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{}) - require.NoError(t, err) - assert.Equal(t, 1, page.Pagination.Page) - assert.Equal(t, walletTransactionsPageLimit, repo.filters[0].Limit) - assert.Zero(t, repo.filters[0].Offset) - assert.Nil(t, repo.filters[0].From) - assert.Empty(t, repo.filters[0].Types) -} - -func TestWalletQueryProcessor_RejectsBadQueries(t *testing.T) { - for name, q := range map[string]models.ListCustomerWalletTransactionsQuery{ - "negative page": {Page: -1}, - "limit too big": {Limit: walletTransactionsMaxLimit + 1}, - "negative limit": {Limit: -1}, - "unknown currency": {Currency: "GOLD"}, - "unknown type": {Type: "EARN,BONUS"}, - "bad from": {From: "01-05-2026"}, - "bad to": {To: "2026-13-01"}, - "from after to": {From: "2026-06-02", To: "2026-06-01"}, - } { - t.Run(name, func(t *testing.T) { - repo := &walletQueryRepoFake{org: uuid.New()} - _, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), uuid.New(), q) - assert.ErrorIs(t, err, ErrInvalidWalletQuery) - assert.Empty(t, repo.filters, "nothing is queried") - }) - } - - // A single day is a valid range. - repo := &walletQueryRepoFake{org: uuid.New()} - _, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), uuid.New(), - models.ListCustomerWalletTransactionsQuery{From: "2026-06-01", To: "2026-06-01"}) - assert.NoError(t, err) -} - -func (f *walletQueryRepoFake) OrganizationOutstanding(context.Context, uuid.UUID) (int64, int64, error) { - return 0, 0, nil -} - -func (f *walletQueryRepoFake) ExpiringByDay(context.Context, uuid.UUID, time.Time) ([]repository.WalletExpiringAmount, error) { - return f.expiring, nil -} - -func TestWalletQueryProcessor_ExpiringGroupsByCurrencyAndDay(t *testing.T) { - repo := &walletQueryRepoFake{org: uuid.New(), expiring: []repository.WalletExpiringAmount{ - {Currency: "POINT", Date: "2026-10-31", Amount: 150}, - {Currency: "COIN", Date: "2026-10-31", Amount: 4}, - {Currency: "POINT", Date: "2026-12-31", Amount: 200}, - }} - got, err := newWalletQueryTest(repo, nil).Expiring(context.Background(), uuid.New()) - require.NoError(t, err) - assert.Equal(t, []models.CustomerWalletExpiring{{Amount: 150, Date: "2026-10-31"}, {Amount: 200, Date: "2026-12-31"}}, got.Point) - assert.Equal(t, []models.CustomerWalletExpiring{{Amount: 4, Date: "2026-10-31"}}, got.Coin) - - empty, err := newWalletQueryTest(&walletQueryRepoFake{org: uuid.New()}, nil).Expiring(context.Background(), uuid.New()) - require.NoError(t, err) - assert.NotNil(t, empty.Point, "an empty list, not null") - assert.NotNil(t, empty.Coin) -} diff --git a/internal/processor/wallet_trace_processor.go b/internal/processor/wallet_trace_processor.go deleted file mode 100644 index dc37e60..0000000 --- a/internal/processor/wallet_trace_processor.go +++ /dev/null @@ -1,180 +0,0 @@ -package processor - -import ( - "context" - "time" - - "github.com/google/uuid" - - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// walletTraceMaxDepth bounds how far back a chain is followed. Every step is a -// transfer, exchange or refund of the step before, so real chains are short; the -// bound only guards against a loop in bad data. -const walletTraceMaxDepth = 100 - -// WalletTraceProcessor traces a ledger row lot by lot for the dashboard -// (docs/prd-point-coin.md F7, §8.1). It only reads. -type WalletTraceProcessor struct { - repo repository.WalletTraceRepository - now func() time.Time -} - -func NewWalletTraceProcessor(repo repository.WalletTraceRepository) *WalletTraceProcessor { - return &WalletTraceProcessor{repo: repo, now: time.Now} -} - -type walletTraceStart struct { - lotID uuid.UUID - amount int64 -} - -// Trace follows a row of the organization's ledger: a debit through the lots it took -// from, a credit through the lots it created, and each lot back through origin_lot_id -// to the lot that first brought the balance in. A row of another organization is -// repository.ErrWalletTransactionNotFound. -func (p *WalletTraceProcessor) Trace(ctx context.Context, organizationID, transactionID uuid.UUID) (*models.AdminWalletTrace, error) { - row, err := p.repo.GetTransaction(ctx, organizationID, transactionID) - if err != nil { - return nil, err - } - - var starts []walletTraceStart - if row.Amount < 0 { - allocations, err := p.repo.ListAllocations(ctx, row.ID) - if err != nil { - return nil, err - } - for _, a := range allocations { - starts = append(starts, walletTraceStart{lotID: a.LotID, amount: a.Amount}) - } - } else { - created, err := p.repo.ListLotsBySource(ctx, row.ID) - if err != nil { - return nil, err - } - for _, lot := range created { - starts = append(starts, walletTraceStart{lotID: lot.ID, amount: lot.OriginalAmount}) - } - } - - lots, err := p.loadChains(ctx, starts) - if err != nil { - return nil, err - } - - sourceIDs := make([]uuid.UUID, 0, len(lots)) - customerIDs := []uuid.UUID{row.CustomerID} - for _, lot := range lots { - sourceIDs = append(sourceIDs, lot.SourceTransactionID) - customerIDs = append(customerIDs, lot.CustomerID) - } - sourceRows, err := p.repo.GetTransactions(ctx, uniqueIDs(sourceIDs)) - if err != nil { - return nil, err - } - sources := make(map[uuid.UUID]entities.WalletTransaction, len(sourceRows)) - for _, s := range sourceRows { - sources[s.ID] = s - } - names, err := p.repo.CustomerNames(ctx, uniqueIDs(customerIDs)) - if err != nil { - return nil, err - } - - now := p.now() - trace := &models.AdminWalletTrace{ - Transaction: walletTraceRow(*row, names), - Lots: make([]models.AdminWalletTraceLot, 0, len(starts)), - } - for _, start := range starts { - item := models.AdminWalletTraceLot{Amount: start.amount, Chain: []models.AdminWalletTraceStep{}} - visited := map[uuid.UUID]bool{} - for id := &start.lotID; id != nil && !visited[*id]; { - lot, ok := lots[*id] - if !ok { - break - } - visited[*id] = true - item.Chain = append(item.Chain, models.AdminWalletTraceStep{ - Lot: adminWalletLot(lot, now), - Source: walletTraceRow(sources[lot.SourceTransactionID], names), - }) - id = lot.OriginLotID - } - trace.Lots = append(trace.Lots, item) - } - return trace, nil -} - -// loadChains loads the starting lots and every lot they were carried over from, one -// generation at a time. -func (p *WalletTraceProcessor) loadChains(ctx context.Context, starts []walletTraceStart) (map[uuid.UUID]entities.WalletLot, error) { - lots := map[uuid.UUID]entities.WalletLot{} - pending := make([]uuid.UUID, 0, len(starts)) - for _, s := range starts { - pending = append(pending, s.lotID) - } - for depth := 0; len(pending) > 0 && depth < walletTraceMaxDepth; depth++ { - found, err := p.repo.GetLots(ctx, uniqueIDs(pending)) - if err != nil { - return nil, err - } - pending = pending[:0] - for _, lot := range found { - lots[lot.ID] = lot - } - for _, lot := range found { - if lot.OriginLotID != nil { - if _, seen := lots[*lot.OriginLotID]; !seen { - pending = append(pending, *lot.OriginLotID) - } - } - } - } - return lots, nil -} - -func walletTraceRow(row entities.WalletTransaction, names map[uuid.UUID]string) models.AdminWalletTraceRow { - return models.AdminWalletTraceRow{ - ID: row.ID, - Customer: models.AdminWalletNamedRef{ID: row.CustomerID, Name: names[row.CustomerID]}, - Currency: row.Currency, - Type: row.Type, - Amount: row.Amount, - Description: row.Description, - ReferenceType: row.ReferenceType, - ReferenceID: row.ReferenceID, - OutletID: row.OutletID, - CreatedAt: row.CreatedAt, - } -} - -func adminWalletLot(lot entities.WalletLot, now time.Time) models.AdminWalletLot { - return models.AdminWalletLot{ - ID: lot.ID, - Currency: lot.Currency, - OriginalAmount: lot.OriginalAmount, - RemainingAmount: lot.RemainingAmount, - ExpiresAt: lot.ExpiresAt, - Expired: lot.ExpiresAt != nil && !lot.ExpiresAt.After(now), - SourceTransactionID: lot.SourceTransactionID, - OriginLotID: lot.OriginLotID, - CreatedAt: lot.CreatedAt, - } -} - -func uniqueIDs(ids []uuid.UUID) []uuid.UUID { - seen := make(map[uuid.UUID]bool, len(ids)) - out := make([]uuid.UUID, 0, len(ids)) - for _, id := range ids { - if !seen[id] { - seen[id] = true - out = append(out, id) - } - } - return out -} diff --git a/internal/processor/wallet_trace_processor_test.go b/internal/processor/wallet_trace_processor_test.go deleted file mode 100644 index 2a0aa01..0000000 --- a/internal/processor/wallet_trace_processor_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package processor - -import ( - "context" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// walletTraceRepoFake reads the fake wallet the way the trace repository reads the -// tables. -type walletTraceRepoFake struct{ e *walletMoveEnv } - -func (f walletTraceRepoFake) GetTransaction(_ context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error) { - for _, tx := range f.e.repo.transactions { - if tx.ID == id && tx.OrganizationID == organizationID { - c := *tx - return &c, nil - } - } - return nil, repository.ErrWalletTransactionNotFound -} - -func (f walletTraceRepoFake) GetTransactions(_ context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error) { - var out []entities.WalletTransaction - for _, tx := range f.e.repo.transactions { - if containsID(ids, tx.ID) { - out = append(out, *tx) - } - } - return out, nil -} - -func (f walletTraceRepoFake) ListAllocations(ctx context.Context, id uuid.UUID) ([]entities.WalletLotAllocation, error) { - return f.e.repo.ListAllocationsByTransaction(ctx, id) -} - -func (f walletTraceRepoFake) ListLotsBySource(ctx context.Context, id uuid.UUID) ([]entities.WalletLot, error) { - return f.e.repo.ListLotsBySourceTransaction(ctx, id) -} - -func (f walletTraceRepoFake) GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) { - return f.e.repo.GetLotsByIDs(ctx, ids) -} - -func (f walletTraceRepoFake) CustomerNames(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) { - names := map[uuid.UUID]string{} - for _, id := range ids { - if c, ok := f.e.customers.byID[id]; ok { - names[id] = c.Name - } - } - return names, nil -} - -func containsID(ids []uuid.UUID, id uuid.UUID) bool { - for _, v := range ids { - if v == id { - return true - } - } - return false -} - -func findRow(t *testing.T, e *walletMoveEnv, customerID uuid.UUID, txType string) *entities.WalletTransaction { - t.Helper() - for _, tx := range e.repo.transactions { - if tx.CustomerID == customerID && tx.Type == txType { - return tx - } - } - t.Fatalf("no %s row for %s", txType, customerID) - return nil -} - -// The example of §8: A has 100 from #ORD-1 and 50 from #ORD-2, sends 120 to B, and B -// pays 30. Tracing B's payment leads to A's order #ORD-1. -func TestWalletTrace_PaymentLeadsBackToTheSendersOrder(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - b := e.member("Budi Santoso", "081234561234") - ord1 := earn(a, 100, e.at(30*24*time.Hour)) - ord1.Description = "Belanja #ORD-1" - ord2 := earn(a, 50, e.at(60*24*time.Hour)) - ord2.Description = "Belanja #ORD-2" - e.credit(t, ord1) - e.credit(t, ord2) - _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - payment, err := e.p.Debit(e.ctx, pay(b, 30)) - require.NoError(t, err) - - p := NewWalletTraceProcessor(walletTraceRepoFake{e}) - trace, err := p.Trace(e.ctx, e.org, payment.Transaction.ID) - require.NoError(t, err) - - assert.Equal(t, constants.WalletTxTypePayment, trace.Transaction.Type) - assert.Equal(t, "Budi Santoso", trace.Transaction.Customer.Name) - require.Len(t, trace.Lots, 1) - assert.Equal(t, int64(30), trace.Lots[0].Amount) - chain := trace.Lots[0].Chain - require.Len(t, chain, 2) - assert.Equal(t, constants.WalletTxTypeTransferIn, chain[0].Source.Type) - assert.Equal(t, b, chain[0].Source.Customer.ID) - assert.Equal(t, constants.WalletTxTypeEarn, chain[1].Source.Type) - assert.Equal(t, "Anita", chain[1].Source.Customer.Name) - assert.Equal(t, "Belanja #ORD-1", chain[1].Source.Description) - assert.Equal(t, constants.WalletRefTypeOrder, chain[1].Source.ReferenceType) - assert.Equal(t, ord1.ReferenceID, chain[1].Source.ReferenceID) - assert.Nil(t, chain[1].Lot.OriginLotID, "the chain ends at the lot the order created") - assert.Equal(t, *chain[0].Lot.OriginLotID, chain[1].Lot.ID) -} - -func TestWalletTrace_DebitAndCreditOfATransfer(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - b := e.member("Budi", "081234561234") - e.credit(t, earn(a, 100, e.at(time.Hour))) - e.credit(t, earn(a, 50, nil)) - _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - p := NewWalletTraceProcessor(walletTraceRepoFake{e}) - - // The sending side took from both of A's lots, which were A's own earnings. - out, err := p.Trace(e.ctx, e.org, findRow(t, e, a, constants.WalletTxTypeTransferOut).ID) - require.NoError(t, err) - require.Len(t, out.Lots, 2) - assert.Equal(t, []int64{100, 20}, []int64{out.Lots[0].Amount, out.Lots[1].Amount}) - for _, lot := range out.Lots { - require.Len(t, lot.Chain, 1) - assert.Equal(t, constants.WalletTxTypeEarn, lot.Chain[0].Source.Type) - } - - // The receiving side created two lots, each carried over from one of A's. - in, err := p.Trace(e.ctx, e.org, findRow(t, e, b, constants.WalletTxTypeTransferIn).ID) - require.NoError(t, err) - require.Len(t, in.Lots, 2) - for _, lot := range in.Lots { - require.Len(t, lot.Chain, 2) - assert.Equal(t, b, lot.Chain[0].Source.Customer.ID) - assert.Equal(t, a, lot.Chain[1].Source.Customer.ID) - assert.Equal(t, lot.Chain[1].Lot.ExpiresAt, lot.Chain[0].Lot.ExpiresAt) - } -} - -func TestWalletTrace_OtherOrganizationsRowsAreNotFound(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - res := e.credit(t, earn(a, 10, nil)) - - _, err := NewWalletTraceProcessor(walletTraceRepoFake{e}).Trace(e.ctx, uuid.New(), res.Transaction.ID) - assert.ErrorIs(t, err, repository.ErrWalletTransactionNotFound) -} diff --git a/internal/processor/wallet_transfer_processor.go b/internal/processor/wallet_transfer_processor.go deleted file mode 100644 index 6c2d7c1..0000000 --- a/internal/processor/wallet_transfer_processor.go +++ /dev/null @@ -1,307 +0,0 @@ -package processor - -import ( - "context" - "errors" - "fmt" - "strconv" - "strings" - "time" - "unicode/utf8" - - "github.com/google/uuid" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// ErrWalletRecipientNotFound means no customer of the sender's organization has the -// phone number. A customer of another organization is reported the same way, so the -// check does not reveal who uses the app elsewhere. -var ErrWalletRecipientNotFound = errors.New("no customer of this organization has that phone number") - -// customerNotifier pushes a notification to a customer's app through FCM. -// CustomerDeviceProcessor is one. -type customerNotifier interface { - Notify(ctx context.Context, customerID uuid.UUID, title, body string, data map[string]string) error -} - -// NotificationTypeWalletTransferIn is the data type of the push a transfer recipient -// gets, so the app can open the transaction. -const NotificationTypeWalletTransferIn = "WALLET_TRANSFER_IN" - -// WalletTransferProcessor sends EnakPoint or EnakCoin from one customer to another in -// the same organization (docs/prd-point-coin.md F5). -type WalletTransferProcessor struct { - customers repository.WalletMoveRepository - settings organizationSettingsReader - spendable spendableReader - pins pinVerifier - wallet *WalletProcessor - tx TxRunner - notifier customerNotifier - now func() time.Time -} - -func NewWalletTransferProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletTransferProcessor { - return &WalletTransferProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, notifier: notifier, now: time.Now} -} - -// Recipient is GET /customer/wallet/transfer/recipient: the masked name and number -// of the customer a phone number belongs to, if the sender may send to them. -func (p *WalletTransferProcessor) Recipient(ctx context.Context, senderID uuid.UUID, phoneNumber string) (*models.WalletTransferRecipient, error) { - sender, err := p.customers.GetCustomer(ctx, senderID) - if err != nil { - return nil, err - } - recipient, err := p.recipient(ctx, sender, phoneNumber) - if err != nil { - return nil, err - } - return maskedRecipient(recipient), nil -} - -// Transfer sends in.Amount of in.Currency to the customer with in.RecipientPhone, -// approved by the sender's PIN (K8), and tells the recipient. -// -// Both wallets are locked in customer_id order, so two transfers in opposite -// directions cannot deadlock. TRANSFER_OUT takes from the sender's lots in K9 order, -// and TRANSFER_IN gives the recipient lots with exactly the same expiries, pointing -// back at the sender's lots, so sending a balance back and forth cannot extend it. -// The two rows share a group and name each other's customer. -// -// idempotencyKey is the client's Idempotency-Key: a retry with the same key returns -// the first transfer without moving anything again or counting against the limits. -func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UUID, in models.WalletTransfer, pin, idempotencyKey string, info models.CustomerPinRequestInfo) (*models.WalletTransferResult, error) { - reject := func(format string, args ...any) error { - return fmt.Errorf("%w: %s", ErrWalletMoveRejected, fmt.Sprintf(format, args...)) - } - key, err := walletMoveKey(idempotencyKey) - if err != nil { - return nil, err - } - currency := strings.ToUpper(strings.TrimSpace(in.Currency)) - if !constants.IsValidWalletCurrency(currency) { - return nil, reject("currency must be POINT or COIN") - } - if in.Amount <= 0 { - return nil, reject("the amount must be positive") - } - sender, err := p.customers.GetCustomer(ctx, senderID) - if err != nil { - return nil, err - } - if !sender.IsActive { - return nil, reject("the customer is not active") - } - settings, err := p.settings.Organization(ctx, sender.OrganizationID) - if err != nil { - return nil, err - } - limits := settings.Transfer - switch { - case !limits.Enabled: - return nil, reject("transfers are turned off") - case in.Amount < limits.MinAmount: - return nil, reject("at least %d can be sent at a time", limits.MinAmount) - case limits.MaxPerTransaction != nil && in.Amount > *limits.MaxPerTransaction: - return nil, reject("at most %d can be sent at a time", *limits.MaxPerTransaction) - } - recipient, err := p.recipient(ctx, sender, in.RecipientPhone) - if err != nil { - return nil, err - } - // Everything the request alone can get wrong is refused above, before the PIN, so - // it costs no attempt. The PIN also refuses a transfer held after a PIN reset. - if err := p.pins.VerifyPin(ctx, senderID, pin, PinActionTransfer, info); err != nil { - return nil, err - } - - to, from := maskedRecipient(recipient), maskedRecipient(sender) - outKey := fmt.Sprintf("transfer:%s:%s:out", senderID, key) - inKey := fmt.Sprintf("transfer:%s:%s:in", senderID, key) - result := &models.WalletTransferResult{Currency: currency, Amount: in.Amount, Recipient: *to} - var receivedID uuid.UUID - err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { - if err := p.wallet.LockWallets(ctx, senderID, recipient.ID); err != nil { - return err - } - groupID, outID, inID := uuid.New(), uuid.New(), uuid.New() - previous, err := p.wallet.FindTransaction(ctx, outKey) - if err != nil { - return err - } - if previous != nil { - // A retry: it replays below, so it must not count against the daily limit - // it is already part of. - if previous.CounterpartyCustomerID == nil || *previous.CounterpartyCustomerID != recipient.ID || previous.GroupID == nil { - return ErrWalletIdempotencyConflict - } - outID, inID, groupID = previous.ID, previous.ReferenceID, *previous.GroupID - } else if limits.DailyLimit != nil { - sent, err := p.customers.TransferredOutSince(ctx, senderID, currency, startOfWalletDay(p.now())) - if err != nil { - return err - } - if sent+in.Amount > *limits.DailyLimit { - return reject("at most %d can be sent per day; %d is left today", *limits.DailyLimit, max(*limits.DailyLimit-sent, 0)) - } - } - - out, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ - TransactionID: outID, - CustomerID: senderID, - Currency: currency, - Type: constants.WalletTxTypeTransferOut, - Amount: in.Amount, - ReferenceType: constants.WalletRefTypeWalletTx, - ReferenceID: inID, - GroupID: &groupID, - CounterpartyCustomerID: &recipient.ID, - Description: truncateRunes(fmt.Sprintf("Transfer ke %s (%s)", to.Name, to.PhoneNumber), walletDescriptionLimit), - IdempotencyKey: outKey, - }}) - if errors.Is(err, repository.ErrWalletInsufficientBalance) { - return reject("not enough %s", walletCurrencyName(currency)) - } - if err != nil { - return err - } - received, err := p.wallet.Credit(ctx, WalletCreditInput{ - WalletEntry: WalletEntry{ - TransactionID: inID, - CustomerID: recipient.ID, - Currency: currency, - Type: constants.WalletTxTypeTransferIn, - Amount: in.Amount, - ReferenceType: constants.WalletRefTypeWalletTx, - ReferenceID: outID, - GroupID: &groupID, - CounterpartyCustomerID: &senderID, - Description: truncateRunes(fmt.Sprintf("Transfer dari %s (%s)", from.Name, from.PhoneNumber), walletDescriptionLimit), - IdempotencyKey: inKey, - }, - Lots: out.CarryOver(), - }) - if err != nil { - return err - } - result.GroupID = groupID - result.Lots = movedLots(received.Lots) - result.Replayed = out.Replayed - receivedID = received.Transaction.ID - return nil - }) - if err != nil { - return nil, err - } - - if !result.Replayed { - p.tellRecipient(ctx, recipient.ID, from, currency, in.Amount, receivedID, result.GroupID) - } - balances, err := p.spendable.SpendableBalances(ctx, senderID, p.now()) - if err != nil { - return nil, err - } - result.Balance = balances[currency] - return result, nil -} - -// recipient finds who a phone number belongs to and checks the sender may send to -// them: an active customer of the same organization, not the walk-in customer, and -// not the sender. -func (p *WalletTransferProcessor) recipient(ctx context.Context, sender *repository.WalletMoveCustomer, phoneNumber string) (*repository.WalletMoveCustomer, error) { - phoneNumber = strings.TrimSpace(phoneNumber) - if phoneNumber == "" { - return nil, fmt.Errorf("%w: the recipient's phone number is required", ErrWalletMoveRejected) - } - recipient, err := p.customers.FindCustomerByPhone(ctx, phoneNumber) - if errors.Is(err, repository.ErrWalletNotFound) { - return nil, ErrWalletRecipientNotFound - } - if err != nil { - return nil, err - } - switch { - case recipient.OrganizationID != sender.OrganizationID: - return nil, ErrWalletRecipientNotFound - case recipient.ID == sender.ID: - return nil, fmt.Errorf("%w: you cannot send to yourself", ErrWalletMoveRejected) - case recipient.IsDefault || !recipient.IsActive: - return nil, fmt.Errorf("%w: this customer cannot receive transfers", ErrWalletMoveRejected) - } - return recipient, nil -} - -// tellRecipient pushes the transfer to the recipient's app (F5). It is best effort: -// the transfer has already happened, so a failure to send is only logged. -func (p *WalletTransferProcessor) tellRecipient(ctx context.Context, recipientID uuid.UUID, sender *models.WalletTransferRecipient, currency string, amount int64, transactionID, groupID uuid.UUID) { - if p.notifier == nil { - return - } - name := walletCurrencyName(currency) - title := name + " masuk" - body := fmt.Sprintf("Kamu menerima %d %s dari %s (%s).", amount, name, sender.Name, sender.PhoneNumber) - data := map[string]string{ - "type": NotificationTypeWalletTransferIn, - "transaction_id": transactionID.String(), - "group_id": groupID.String(), - "currency": currency, - "amount": strconv.FormatInt(amount, 10), - } - if err := p.notifier.Notify(ctx, recipientID, title, body, data); err != nil { - logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about a transfer", recipientID), err) - } -} - -func maskedRecipient(c *repository.WalletMoveCustomer) *models.WalletTransferRecipient { - phone := "" - if c.PhoneNumber != nil { - phone = maskPhoneNumber(*c.PhoneNumber) - } - return &models.WalletTransferRecipient{Name: maskName(c.Name), PhoneNumber: phone} -} - -// maskName keeps the first two letters of each word, "Budi Santoso" → "Bu*** Sa***", -// and one letter of a word that short, so the sender can recognise the recipient -// without the app revealing their name (F5, §8.1). -func maskName(name string) string { - words := strings.Fields(name) - if len(words) == 0 { - return "***" - } - for i, w := range words { - keep := 2 - if utf8.RuneCountInString(w) <= 2 { - keep = 1 - } - words[i] = string([]rune(w)[:keep]) + "***" - } - return strings.Join(words, " ") -} - -// maskPhoneNumber keeps the first two and the last four digits: -// "081234561234" → "08**-****-1234". -func maskPhoneNumber(phone string) string { - runes := []rune(strings.TrimSpace(phone)) - if len(runes) < 8 { - return "****" - } - return string(runes[:2]) + "**-****-" + string(runes[len(runes)-4:]) -} - -func walletCurrencyName(currency string) string { - if currency == constants.WalletCurrencyCoin { - return "EnakCoin" - } - return "EnakPoint" -} - -// startOfWalletDay is midnight of t's day in the customer's time zone, where the -// daily transfer limit starts over. -func startOfWalletDay(t time.Time) time.Time { - local := t.In(walletDisplayLocation) - return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, walletDisplayLocation) -} diff --git a/internal/processor/wallet_transfer_processor_test.go b/internal/processor/wallet_transfer_processor_test.go deleted file mode 100644 index 090ca88..0000000 --- a/internal/processor/wallet_transfer_processor_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package processor - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -type pushFake struct { - title, body string - data map[string]string -} - -// notifierFake records the pushes each customer would get. -type notifierFake struct{ pushes map[uuid.UUID][]pushFake } - -func (f *notifierFake) Notify(_ context.Context, customerID uuid.UUID, title, body string, data map[string]string) error { - if f.pushes == nil { - f.pushes = map[uuid.UUID][]pushFake{} - } - f.pushes[customerID] = append(f.pushes[customerID], pushFake{title: title, body: body, data: data}) - return nil -} - -func sendPoints(amount int64, phone string) models.WalletTransfer { - return models.WalletTransfer{Currency: constants.WalletCurrencyPoint, Amount: amount, RecipientPhone: phone} -} - -func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - b := e.member("Budi Santoso", "081234561234") - dec, jan := e.at(30*24*time.Hour), e.at(60*24*time.Hour) - first := e.credit(t, earn(a, 100, dec)) - second := e.credit(t, earn(a, 50, jan)) - notifier := ¬ifierFake{} - - // The example in §8: A sends 120, 100 from the lot expiring first and 20 from the next. - res, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - - assert.Equal(t, int64(30), res.Balance) - assert.Equal(t, models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, res.Recipient) - assert.Equal(t, []models.WalletMovedLot{{Amount: 100, ExpiresAt: dec}, {Amount: 20, ExpiresAt: jan}}, res.Lots) - assert.Equal(t, int64(120), e.balance(t, b)) - assert.Equal(t, []PinAction{PinActionTransfer}, e.pins.actions) - - var bLots []uuid.UUID - for _, lot := range e.repo.lots { - if lot.CustomerID == b { - require.NotNil(t, lot.OriginLotID) - bLots = append(bLots, *lot.OriginLotID) - for _, origin := range e.repo.lots { - if origin.ID == *lot.OriginLotID { - assert.Equal(t, origin.ExpiresAt, lot.ExpiresAt, "the recipient's lot expires exactly when the sender's did") - } - } - } - } - assert.Equal(t, []uuid.UUID{first.Lots[0].ID, second.Lots[0].ID}, bLots) - - out, in := e.repo.transactions[2], e.repo.transactions[3] - assert.Equal(t, constants.WalletTxTypeTransferOut, out.Type) - assert.Equal(t, b, *out.CounterpartyCustomerID) - assert.Equal(t, in.ID, out.ReferenceID) - assert.Equal(t, "Transfer ke Bu*** Sa*** (08**-****-1234)", out.Description) - assert.Equal(t, constants.WalletTxTypeTransferIn, in.Type) - assert.Equal(t, a, *in.CounterpartyCustomerID) - assert.Equal(t, out.ID, in.ReferenceID) - assert.Equal(t, *out.GroupID, *in.GroupID) - assert.Equal(t, "Transfer dari An*** (08**-****-5678)", in.Description) - assert.GreaterOrEqual(t, e.repo.locks[a], 1) - assert.GreaterOrEqual(t, e.repo.locks[b], 1) - - assert.Equal(t, []pushFake{{ - title: "EnakPoint masuk", - body: "Kamu menerima 120 EnakPoint dari An*** (08**-****-5678).", - data: map[string]string{ - "type": NotificationTypeWalletTransferIn, - "transaction_id": in.ID.String(), - "group_id": in.GroupID.String(), - "currency": constants.WalletCurrencyPoint, - "amount": "120", - }, - }}, notifier.pushes[b]) - assert.Empty(t, notifier.pushes[a], "the sender gets no push") -} - -func TestWalletTransfer_Coins(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - b := e.member("Budi", "081234561234") - e.earnCoins(t, a, 10, nil) - - _, err := e.transfers(nil).Transfer(e.ctx, a, models.WalletTransfer{Currency: "coin", Amount: 4, RecipientPhone: "081234561234"}, "482913", "key-1", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - assert.Equal(t, int64(6), e.coinBalance(t, a)) - assert.Equal(t, int64(4), e.coinBalance(t, b)) -} - -func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - e.credit(t, earn(a, 100, nil)) - - walkIn := e.member("Walk-in", "081100000000") - e.customers.byID[walkIn].IsDefault = true - inactive := e.member("Old", "081100000001") - e.customers.byID[inactive].IsActive = false - elsewhere := e.member("Other Org", "081100000002") - e.customers.byID[elsewhere].OrganizationID = uuid.New() - - for phone, want := range map[string]error{ - "081200005678": ErrWalletMoveRejected, // herself - "081100000000": ErrWalletMoveRejected, // the walk-in customer - "081100000001": ErrWalletMoveRejected, // inactive - "081100000002": ErrWalletRecipientNotFound, // another organization looks like nobody - "081999999999": ErrWalletRecipientNotFound, - "": ErrWalletMoveRejected, - } { - _, err := e.transfers(nil).Recipient(e.ctx, a, phone) - assert.ErrorIs(t, err, want, phone) - _, err = e.transfers(nil).Transfer(e.ctx, a, sendPoints(10, phone), "482913", "key-"+phone, models.CustomerPinRequestInfo{}) - assert.ErrorIs(t, err, want, phone) - } - assert.Empty(t, e.pins.actions, "refused before the PIN") - assert.Equal(t, int64(100), e.balance(t, a)) -} - -func TestWalletTransfer_RecipientIsMasked(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - e.member("Budi Santoso", "081234561234") - - got, err := e.transfers(nil).Recipient(e.ctx, a, " 081234561234 ") - require.NoError(t, err) - assert.Equal(t, &models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, got) -} - -func TestWalletTransfer_OrganizationLimits(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - e.member("Budi", "081234561234") - e.credit(t, earn(a, 1000, nil)) - e.settings.Transfer = models.LoyaltyTransferSettings{Enabled: true, MinAmount: 10, MaxPerTransaction: ptr(int64(300)), DailyLimit: ptr(int64(500))} - send := func(amount int64, key string) error { - _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(amount, "081234561234"), "482913", key, models.CustomerPinRequestInfo{}) - return err - } - - assert.ErrorIs(t, send(9, "below-min"), ErrWalletMoveRejected) - assert.ErrorIs(t, send(301, "above-max"), ErrWalletMoveRejected) - require.NoError(t, send(300, "k1")) - require.NoError(t, send(200, "k2")) - // The daily limit is used up; a retry of a transfer already made still replays. - assert.ErrorIs(t, send(10, "k3"), ErrWalletMoveRejected) - require.NoError(t, send(200, "k2")) - - // It starts over the next day in the customer's time zone. - e.now = startOfWalletDay(e.now).AddDate(0, 0, 1).Add(time.Minute) - e.repo.clock = e.now - require.NoError(t, send(10, "k4")) - assert.Equal(t, int64(490), e.balance(t, a)) - - e.settings.Transfer.Enabled = false - assert.ErrorIs(t, send(10, "k5"), ErrWalletMoveRejected) -} - -func TestWalletTransfer_HeldAfterPinReset(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - e.member("Budi", "081234561234") - e.credit(t, earn(a, 100, nil)) - until := e.now.Add(time.Hour) - e.pins.err = &PinError{Code: PinErrTransferBlocked, Until: &until} - - _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(10, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) - var pinErr *PinError - require.True(t, errors.As(err, &pinErr)) - assert.Equal(t, PinErrTransferBlocked, pinErr.Code) - assert.Equal(t, int64(100), e.balance(t, a)) -} - -func TestWalletTransfer_NotEnoughBalance(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - b := e.member("Budi", "081234561234") - e.credit(t, earn(a, 100, nil)) - // An expired lot cannot be sent even before the expiry job takes it. - e.credit(t, earn(a, 50, e.at(-time.Hour))) - - _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) - assert.ErrorIs(t, err, ErrWalletMoveRejected) - assert.Equal(t, int64(0), e.balance(t, b)) -} - -func TestWalletTransfer_RetryMovesNothingAndTellsNobodyAgain(t *testing.T) { - e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - b := e.member("Budi", "081234561234") - e.member("Citra", "081255550000") - e.credit(t, earn(a, 100, nil)) - notifier := ¬ifierFake{} - - first, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - again, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - - assert.True(t, again.Replayed) - assert.Equal(t, first.GroupID, again.GroupID) - assert.Equal(t, int64(40), e.balance(t, b)) - assert.Len(t, notifier.pushes[b], 1) - - // The same key to someone else is not a retry. - _, err = e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081255550000"), "482913", "key-1", models.CustomerPinRequestInfo{}) - assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) -} - -func TestWalletTransfer_UnknownSender(t *testing.T) { - e := newWalletMoveEnv(t) - _, err := e.transfers(nil).Transfer(e.ctx, uuid.New(), sendPoints(1, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) - assert.ErrorIs(t, err, repository.ErrWalletNotFound) -} - -func TestMaskName(t *testing.T) { - assert.Equal(t, "Bu*** Sa***", maskName("Budi Santoso")) - assert.Equal(t, "An***", maskName("Anita")) - assert.Equal(t, "A*** Ra***", maskName("Al Rahman")) - assert.Equal(t, "***", maskName(" ")) - assert.Equal(t, "08**-****-1234", maskPhoneNumber("081234561234")) - assert.Equal(t, "+6**-****-1234", maskPhoneNumber("+6281234561234")) - assert.Equal(t, "****", maskPhoneNumber("12345")) -} diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index a4fddcb..1696973 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -99,8 +99,7 @@ func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, pm.type as payment_method_type, COALESCE(SUM(p.amount), 0) as total_amount, COUNT(DISTINCT p.order_id) as order_count, - COUNT(p.id) as payment_count, - COALESCE(SUM(p.points_used), 0) as points_used + COUNT(p.id) as payment_count `). Joins("JOIN payment_methods pm ON p.payment_method_id = pm.id"). Joins("JOIN orders o ON p.order_id = o.id"). @@ -479,7 +478,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ COALESCE(SUM(oi.weight), 0) as weight_sold, COALESCE(SUM(oi.total_price), 0) as revenue, CASE - WHEN SUM(`+billableQty+`) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(`+billableQty+`) + WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(` + billableQty + `) ELSE 0 END as average_price, COUNT(DISTINCT oi.order_id) as order_count, @@ -494,14 +493,14 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id WHERE pr.product_id = p.id - ), p.cost, 0) * COALESCE(SUM(`+billableQty+`), 0) as standard_hpp_total, + ), p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as standard_hpp_total, CASE - WHEN SUM(`+billableQty+`) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(`+billableQty+`) + WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(` + billableQty + `) ELSE 0 END as fifo_hpp_per_unit, COALESCE(SUM(oi.total_cost), 0) as fifo_hpp_total, COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit, - COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(`+billableQty+`), 0) as moving_average_hpp_total + COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as moving_average_hpp_total `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). @@ -552,9 +551,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_moving_average_hpp + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). @@ -605,9 +604,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte COUNT(DISTINCT c.id) as category_count, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_moving_average_hpp + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). diff --git a/internal/repository/customer_device_repository.go b/internal/repository/customer_device_repository.go deleted file mode 100644 index 5c38807..0000000 --- a/internal/repository/customer_device_repository.go +++ /dev/null @@ -1,84 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -// CustomerDevice is one install of the customer app that can receive push -// notifications. -type CustomerDevice struct { - CustomerID uuid.UUID - DeviceID string - Platform *string - FCMToken string - AppVersion *string -} - -// CustomerDeviceRepository keeps the FCM tokens of the customer app. -type CustomerDeviceRepository interface { - // Register saves the device's current token. A token is only ever one customer's: - // registering it takes it away from whoever had it before on that phone, so they - // stop getting this customer's notifications. - Register(ctx context.Context, device CustomerDevice, now time.Time) error - // Unregister forgets a device, for instance on logout. An unknown device is not an error. - Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error - ListTokens(ctx context.Context, customerID uuid.UUID) ([]string, error) -} - -type customerDeviceRepository struct { - db *gorm.DB -} - -func NewCustomerDeviceRepository(db *gorm.DB) CustomerDeviceRepository { - return &customerDeviceRepository{db: db} -} - -func (r *customerDeviceRepository) Register(ctx context.Context, d CustomerDevice, now time.Time) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Transaction(func(tx *gorm.DB) error { - err := tx.Exec(`DELETE FROM customer_devices WHERE fcm_token = ? AND NOT (customer_id = ? AND device_id = ?)`, - d.FCMToken, d.CustomerID, d.DeviceID).Error - if err != nil { - return fmt.Errorf("failed to release FCM token: %w", err) - } - err = tx.Exec(`INSERT INTO customer_devices (customer_id, device_id, platform, fcm_token, app_version, last_active_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT (customer_id, device_id) DO UPDATE SET - platform = EXCLUDED.platform, - fcm_token = EXCLUDED.fcm_token, - app_version = EXCLUDED.app_version, - last_active_at = EXCLUDED.last_active_at, - updated_at = NOW()`, - d.CustomerID, d.DeviceID, d.Platform, d.FCMToken, d.AppVersion, now).Error - if err != nil { - return fmt.Errorf("failed to register customer device: %w", err) - } - return nil - }) -} - -func (r *customerDeviceRepository) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error { - err := DBFromContext(ctx, r.db).WithContext(ctx). - Exec(`DELETE FROM customer_devices WHERE customer_id = ? AND device_id = ?`, customerID, deviceID).Error - if err != nil { - return fmt.Errorf("failed to unregister customer device: %w", err) - } - return nil -} - -func (r *customerDeviceRepository) ListTokens(ctx context.Context, customerID uuid.UUID) ([]string, error) { - var tokens []string - err := DBFromContext(ctx, r.db).WithContext(ctx). - Table("customer_devices"). - Where("customer_id = ? AND fcm_token <> ''", customerID). - Order("last_active_at DESC NULLS LAST"). - Pluck("fcm_token", &tokens).Error - if err != nil { - return nil, fmt.Errorf("failed to list customer FCM tokens: %w", err) - } - return tokens, nil -} diff --git a/internal/repository/customer_pin_repository.go b/internal/repository/customer_pin_repository.go deleted file mode 100644 index 6c38ab3..0000000 --- a/internal/repository/customer_pin_repository.go +++ /dev/null @@ -1,220 +0,0 @@ -package repository - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -// ErrPinCustomerNotFound means the customer does not exist. -var ErrPinCustomerNotFound = errors.New("pin: customer not found") - -// CustomerPinState is a customer's PIN and what guards it. It lives in the customers -// table but is read and written only here, never through the Customer entity, so the -// hash cannot end up in a customer response. -type CustomerPinState struct { - CustomerID uuid.UUID - OrganizationID uuid.UUID - PhoneNumber *string - BirthDate *time.Time - PinHash *string - PinSetAt *time.Time - FailedAttempts int - LockedUntil *time.Time - TransferBlockedUntil *time.Time -} - -// CustomerSecurityEvent is one row of the PIN security log. -type CustomerSecurityEvent struct { - ID uuid.UUID - CustomerID uuid.UUID - Event string - ActorUser *uuid.UUID - Reason *string - IPAddress *string - UserAgent *string - CreatedAt time.Time -} - -// CustomerPinRepository stores customer PINs and their security log -// (docs/prd-point-coin.md F11). -type CustomerPinRepository interface { - GetState(ctx context.Context, customerID uuid.UUID) (*CustomerPinState, error) - // SetPin stores a new PIN hash, clears the failure counter and any lock, and sets - // or clears the transfer hold. - SetPin(ctx context.Context, customerID uuid.UUID, hash string, transferBlockedUntil *time.Time) error - // RemovePin deletes the PIN, so the customer has to create a new one through OTP. - RemovePin(ctx context.Context, customerID uuid.UUID) error - // RecordFailure adds one wrong attempt in a single statement, so wrong attempts - // made at the same time all count. A lock that has already run out starts the - // count again. When the count reaches maxAttempts the PIN is locked until - // lockUntil. It returns the count and lock after the update. - RecordFailure(ctx context.Context, customerID uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) - ClearFailures(ctx context.Context, customerID uuid.UUID) error - - InsertEvent(ctx context.Context, event CustomerSecurityEvent) error - // ListEvents returns a page of the customer's log, newest first, and the total. - ListEvents(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerSecurityEvent, int64, error) -} - -type customerPinRepository struct { - db *gorm.DB -} - -func NewCustomerPinRepository(db *gorm.DB) CustomerPinRepository { - return &customerPinRepository{db: db} -} - -func (r *customerPinRepository) GetState(ctx context.Context, customerID uuid.UUID) (*CustomerPinState, error) { - var rows []struct { - CustomerID string - OrganizationID string - PhoneNumber *string - BirthDate *time.Time - PinHash *string - PinSetAt *time.Time - PinFailedAttempts int - PinLockedUntil *time.Time - TransferBlockedUntil *time.Time - } - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT id::text AS customer_id, organization_id::text AS organization_id, - COALESCE(phone_number, phone) AS phone_number, birth_date, - pin_hash, pin_set_at, pin_failed_attempts, pin_locked_until, transfer_blocked_until - FROM customers WHERE id = ? LIMIT 1`, customerID).Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to read customer PIN: %w", err) - } - if len(rows) == 0 { - return nil, ErrPinCustomerNotFound - } - row := rows[0] - state := &CustomerPinState{ - PhoneNumber: row.PhoneNumber, - BirthDate: row.BirthDate, - PinHash: row.PinHash, - PinSetAt: row.PinSetAt, - FailedAttempts: row.PinFailedAttempts, - LockedUntil: row.PinLockedUntil, - TransferBlockedUntil: row.TransferBlockedUntil, - } - state.CustomerID, _ = uuid.Parse(row.CustomerID) - state.OrganizationID, _ = uuid.Parse(row.OrganizationID) - return state, nil -} - -func (r *customerPinRepository) SetPin(ctx context.Context, customerID uuid.UUID, hash string, transferBlockedUntil *time.Time) error { - result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` - UPDATE customers SET pin_hash = ?, pin_set_at = NOW(), pin_failed_attempts = 0, - pin_locked_until = NULL, transfer_blocked_until = ?, updated_at = NOW() - WHERE id = ?`, hash, transferBlockedUntil, customerID) - if result.Error != nil { - return fmt.Errorf("failed to store customer PIN: %w", result.Error) - } - if result.RowsAffected == 0 { - return ErrPinCustomerNotFound - } - return nil -} - -func (r *customerPinRepository) RemovePin(ctx context.Context, customerID uuid.UUID) error { - result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` - UPDATE customers SET pin_hash = NULL, pin_set_at = NULL, pin_failed_attempts = 0, - pin_locked_until = NULL, updated_at = NOW() - WHERE id = ?`, customerID) - if result.Error != nil { - return fmt.Errorf("failed to remove customer PIN: %w", result.Error) - } - if result.RowsAffected == 0 { - return ErrPinCustomerNotFound - } - return nil -} - -func (r *customerPinRepository) RecordFailure(ctx context.Context, customerID uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) { - var rows []struct { - PinFailedAttempts int - PinLockedUntil *time.Time - } - // When an earlier lock has run out, this attempt is the first of a new series. - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - UPDATE customers SET - pin_failed_attempts = CASE - WHEN pin_locked_until IS NOT NULL AND pin_locked_until <= @now THEN 1 - ELSE pin_failed_attempts + 1 END, - pin_locked_until = CASE - WHEN pin_locked_until IS NOT NULL AND pin_locked_until <= @now THEN NULL - WHEN pin_failed_attempts + 1 >= @max THEN @lock - ELSE pin_locked_until END - WHERE id = @id - RETURNING pin_failed_attempts, pin_locked_until`, - map[string]interface{}{"now": now, "max": maxAttempts, "lock": lockUntil, "id": customerID}). - Scan(&rows).Error - if err != nil { - return 0, nil, fmt.Errorf("failed to record a wrong PIN: %w", err) - } - if len(rows) == 0 { - return 0, nil, ErrPinCustomerNotFound - } - return rows[0].PinFailedAttempts, rows[0].PinLockedUntil, nil -} - -func (r *customerPinRepository) ClearFailures(ctx context.Context, customerID uuid.UUID) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Exec(` - UPDATE customers SET pin_failed_attempts = 0, pin_locked_until = NULL - WHERE id = ? AND (pin_failed_attempts <> 0 OR pin_locked_until IS NOT NULL)`, customerID).Error -} - -func (r *customerPinRepository) InsertEvent(ctx context.Context, event CustomerSecurityEvent) error { - err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` - INSERT INTO customer_security_events (customer_id, event, actor_user, reason, ip_address, user_agent) - VALUES (?, ?, ?, ?, ?, ?)`, - event.CustomerID, event.Event, event.ActorUser, event.Reason, event.IPAddress, event.UserAgent).Error - if err != nil { - return fmt.Errorf("failed to record security event: %w", err) - } - return nil -} - -func (r *customerPinRepository) ListEvents(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerSecurityEvent, int64, error) { - db := DBFromContext(ctx, r.db).WithContext(ctx) - var total int64 - if err := db.Table("customer_security_events").Where("customer_id = ?", customerID).Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count security events: %w", err) - } - var rows []struct { - ID string - CustomerID string - Event string - ActorUser *string - Reason *string - IPAddress *string - UserAgent *string - CreatedAt time.Time - } - err := db.Raw(` - SELECT id::text AS id, customer_id::text AS customer_id, event, actor_user::text AS actor_user, - reason, ip_address, user_agent, created_at - FROM customer_security_events WHERE customer_id = ? - ORDER BY created_at DESC, id DESC OFFSET ? LIMIT ?`, customerID, offset, limit).Scan(&rows).Error - if err != nil { - return nil, 0, fmt.Errorf("failed to list security events: %w", err) - } - events := make([]CustomerSecurityEvent, 0, len(rows)) - for _, row := range rows { - e := CustomerSecurityEvent{Event: row.Event, Reason: row.Reason, IPAddress: row.IPAddress, UserAgent: row.UserAgent, CreatedAt: row.CreatedAt} - e.ID, _ = uuid.Parse(row.ID) - e.CustomerID, _ = uuid.Parse(row.CustomerID) - if row.ActorUser != nil { - if id, err := uuid.Parse(*row.ActorUser); err == nil { - e.ActorUser = &id - } - } - events = append(events, e) - } - return events, total, nil -} diff --git a/internal/repository/customer_points_repository.go b/internal/repository/customer_points_repository.go new file mode 100644 index 0000000..6ee1677 --- /dev/null +++ b/internal/repository/customer_points_repository.go @@ -0,0 +1,97 @@ +package repository + +import ( + "context" + "fmt" + + "apskel-pos-be/internal/entities" + + "gorm.io/gorm" +) + +type CustomerPointsRepository interface { + GetCustomerTotalPoints(ctx context.Context, customerID string) (int64, error) + GetCustomerTotalTokens(ctx context.Context, customerID string) (int64, error) + GetCustomerPointsHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerPoints, error) + GetCustomerTokensHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerTokens, error) +} + +type customerPointsRepository struct { + db *gorm.DB +} + +func NewCustomerPointsRepository(db *gorm.DB) CustomerPointsRepository { + return &customerPointsRepository{ + db: db, + } +} + +func (r *customerPointsRepository) GetCustomerTotalPoints(ctx context.Context, customerID string) (int64, error) { + var totalPoints int64 + + err := r.db.WithContext(ctx). + Model(&entities.CustomerPoints{}). + Where("customer_id = ?", customerID). + Select("COALESCE(SUM(balance), 0)"). + Scan(&totalPoints).Error + + if err != nil { + return 0, fmt.Errorf("failed to get customer total points: %w", err) + } + + return totalPoints, nil +} + +func (r *customerPointsRepository) GetCustomerTotalTokens(ctx context.Context, customerID string) (int64, error) { + var totalTokens int64 + + err := r.db.WithContext(ctx). + Model(&entities.CustomerTokens{}). + Where("customer_id = ?", customerID). + Select("COALESCE(SUM(balance), 0)"). + Scan(&totalTokens).Error + + if err != nil { + return 0, fmt.Errorf("failed to get customer total tokens: %w", err) + } + + return totalTokens, nil +} + +func (r *customerPointsRepository) GetCustomerPointsHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerPoints, error) { + var pointsHistory []entities.CustomerPoints + + query := r.db.WithContext(ctx). + Where("customer_id = ?", customerID). + Order("created_at DESC") + + if limit > 0 { + query = query.Limit(limit) + } + + err := query.Find(&pointsHistory).Error + if err != nil { + return nil, fmt.Errorf("failed to get customer points history: %w", err) + } + + return pointsHistory, nil +} + +func (r *customerPointsRepository) GetCustomerTokensHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerTokens, error) { + var tokensHistory []entities.CustomerTokens + + query := r.db.WithContext(ctx). + Where("customer_id = ?", customerID). + Order("created_at DESC") + + if limit > 0 { + query = query.Limit(limit) + } + + err := query.Find(&tokensHistory).Error + if err != nil { + return nil, fmt.Errorf("failed to get customer tokens history: %w", err) + } + + return tokensHistory, nil +} diff --git a/internal/repository/customer_tokens_repository.go b/internal/repository/customer_tokens_repository.go new file mode 100644 index 0000000..de29903 --- /dev/null +++ b/internal/repository/customer_tokens_repository.go @@ -0,0 +1,131 @@ +package repository + +import ( + "apskel-pos-be/internal/entities" + "context" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type CustomerTokensRepository struct { + db *gorm.DB +} + +func NewCustomerTokensRepository(db *gorm.DB) *CustomerTokensRepository { + return &CustomerTokensRepository{db: db} +} + +func (r *CustomerTokensRepository) Create(ctx context.Context, customerTokens *entities.CustomerTokens) error { + return r.db.WithContext(ctx).Create(customerTokens).Error +} + +func (r *CustomerTokensRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.CustomerTokens, error) { + var customerTokens entities.CustomerTokens + err := r.db.WithContext(ctx).Preload("Customer").Where("id = ?", id).First(&customerTokens).Error + if err != nil { + return nil, err + } + return &customerTokens, nil +} + +func (r *CustomerTokensRepository) GetByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType) (*entities.CustomerTokens, error) { + var customerTokens entities.CustomerTokens + err := r.db.WithContext(ctx).Preload("Customer").Where("customer_id = ? AND token_type = ?", customerID, tokenType).First(&customerTokens).Error + if err != nil { + return nil, err + } + return &customerTokens, nil +} + +func (r *CustomerTokensRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID) ([]entities.CustomerTokens, error) { + var customerTokens []entities.CustomerTokens + err := r.db.WithContext(ctx).Preload("Customer").Where("customer_id = ?", customerID).Find(&customerTokens).Error + if err != nil { + return nil, err + } + return customerTokens, nil +} + +func (r *CustomerTokensRepository) List(ctx context.Context, offset, limit int, search, tokenType string, sortBy, sortOrder string) ([]entities.CustomerTokens, int64, error) { + var customerTokens []entities.CustomerTokens + var total int64 + + query := r.db.WithContext(ctx).Preload("Customer") + + if search != "" { + searchTerm := "%" + search + "%" + query = query.Joins("JOIN customers ON customer_tokens.customer_id = customers.id"). + Where("customers.name ILIKE ? OR customers.email ILIKE ?", searchTerm, searchTerm) + } + + if tokenType != "" { + query = query.Where("token_type = ?", tokenType) + } + + if err := query.Model(&entities.CustomerTokens{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + if sortBy != "" { + if sortOrder == "" { + sortOrder = "asc" + } + query = query.Order(fmt.Sprintf("customer_tokens.%s %s", sortBy, sortOrder)) + } else { + query = query.Order("customer_tokens.created_at DESC") + } + + err := query.Offset(offset).Limit(limit).Find(&customerTokens).Error + if err != nil { + return nil, 0, err + } + + return customerTokens, total, nil +} + +func (r *CustomerTokensRepository) Update(ctx context.Context, customerTokens *entities.CustomerTokens) error { + return r.db.WithContext(ctx).Save(customerTokens).Error +} + +func (r *CustomerTokensRepository) Delete(ctx context.Context, id uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&entities.CustomerTokens{}, id).Error +} + +func (r *CustomerTokensRepository) AddTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType, tokens int64) error { + return r.db.WithContext(ctx).Model(&entities.CustomerTokens{}). + Where("customer_id = ? AND token_type = ?", customerID, tokenType). + Update("balance", gorm.Expr("balance + ?", tokens)).Error +} + +func (r *CustomerTokensRepository) DeductTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType, tokens int64) error { + return r.db.WithContext(ctx).Model(&entities.CustomerTokens{}). + Where("customer_id = ? AND token_type = ? AND balance >= ?", customerID, tokenType, tokens). + Update("balance", gorm.Expr("balance - ?", tokens)).Error +} + +func (r *CustomerTokensRepository) EnsureCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType) (*entities.CustomerTokens, error) { + customerTokens, err := r.GetByCustomerIDAndType(ctx, customerID, tokenType) + if err == nil { + return customerTokens, nil + } + + if err != gorm.ErrRecordNotFound { + return nil, err + } + + // Create new customer tokens record + newCustomerTokens := &entities.CustomerTokens{ + CustomerID: customerID, + TokenType: tokenType, + Balance: 0, + } + + err = r.Create(ctx, newCustomerTokens) + if err != nil { + return nil, err + } + + return newCustomerTokens, nil +} diff --git a/internal/repository/earning_repository.go b/internal/repository/earning_repository.go deleted file mode 100644 index 81d3711..0000000 --- a/internal/repository/earning_repository.go +++ /dev/null @@ -1,275 +0,0 @@ -package repository - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" -) - -// ErrEarningOrderNotFound means the order does not exist. -var ErrEarningOrderNotFound = errors.New("earning: order not found") - -// EarningOrder is what earning needs to know about an order. -type EarningOrder struct { - ID uuid.UUID - OrganizationID uuid.UUID - OutletID uuid.UUID - OrderNumber string - OutletName string - CustomerID *uuid.UUID - Subtotal float64 - DiscountAmount float64 - PaymentStatus string - IsVoid bool - // Cumulative rupiah refunded so far. - RefundAmount float64 - // Nil when the order has no customer, or the customer row is gone. - CustomerIsDefault *bool - CustomerIsActive *bool -} - -// EarningCursor pages through orders by (updated_at, id). -type EarningCursor struct { - UpdatedAt time.Time - ID uuid.UUID -} - -// EarningRepository reads orders for loyalty earning (docs/prd-point-coin.md F3). -type EarningRepository interface { - GetOrderForEarning(ctx context.Context, orderID uuid.UUID) (*EarningOrder, error) - // PointPaidAmount is the rupiah part of the order paid with EnakPoint, which earns - // nothing (Q10). Zero until EnakPoint payment exists (phase 3). - PointPaidAmount(ctx context.Context, orderID uuid.UUID) (float64, error) - // ListPaidOrdersWithoutEarning pages, oldest first, through orders updated since - // the given time that are paid, not void, have an eligible customer, belong to an - // outlet that earns something, and have no EARN row yet. Pass the previous page's - // last cursor to continue; nil starts at the beginning. - ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error) - // ListEarnTransactions returns the EARN rows written for an order. - ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error) - // ReversalRequested is how much has been asked back from an EARN row so far: what - // was taken plus any shortfall. - ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error) - // LotIDsCreatedBy returns the lots a credit created. - LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error) - // EarnedByOrders sums the EARN rows of each order per currency, as - // map[order]map[currency]amount. Orders that earned nothing are absent. - EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]map[string]int64, error) -} - -type earningRepository struct { - db *gorm.DB -} - -func NewEarningRepository(db *gorm.DB) EarningRepository { - return &earningRepository{db: db} -} - -func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid.UUID) (*EarningOrder, error) { - var rows []struct { - ID string - OrganizationID string - OutletID string - OrderNumber string - OutletName string - CustomerID *string - Subtotal float64 - DiscountAmount float64 - PaymentStatus string - IsVoid bool - RefundAmount float64 - CustomerIsDefault *bool - CustomerIsActive *bool - } - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id, - o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id, - o.subtotal, COALESCE(o.discount_amount, 0) AS discount_amount, o.payment_status, - COALESCE(o.is_void, false) AS is_void, - -- Refunds of EnakPoint payments are left out: that part never earned (Q10), - -- so giving it back must not take earning back. - COALESCE(o.refund_amount, 0) - COALESCE(( - SELECT SUM(COALESCE(p.refund_amount, 0)) FROM payments p - JOIN payment_methods pm ON pm.id = p.payment_method_id - WHERE p.order_id = o.id AND pm.type = 'point'), 0) AS refund_amount, - c.is_default AS customer_is_default, c.is_active AS customer_is_active - FROM orders o - LEFT JOIN outlets ou ON ou.id = o.outlet_id - LEFT JOIN customers c ON c.id = o.customer_id - WHERE o.id = ? - LIMIT 1`, orderID).Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to get order for earning: %w", err) - } - if len(rows) == 0 { - return nil, ErrEarningOrderNotFound - } - row := rows[0] - order := &EarningOrder{ - OrderNumber: row.OrderNumber, - OutletName: row.OutletName, - Subtotal: row.Subtotal, - DiscountAmount: row.DiscountAmount, - PaymentStatus: row.PaymentStatus, - IsVoid: row.IsVoid, - RefundAmount: row.RefundAmount, - CustomerIsDefault: row.CustomerIsDefault, - CustomerIsActive: row.CustomerIsActive, - } - order.ID, _ = uuid.Parse(row.ID) - order.OrganizationID, _ = uuid.Parse(row.OrganizationID) - order.OutletID, _ = uuid.Parse(row.OutletID) - if row.CustomerID != nil { - if id, err := uuid.Parse(*row.CustomerID); err == nil { - order.CustomerID = &id - } - } - return order, nil -} - -func (r *earningRepository) PointPaidAmount(ctx context.Context, orderID uuid.UUID) (float64, error) { - var total float64 - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT COALESCE(SUM(p.amount), 0) - FROM payments p - JOIN payment_methods pm ON pm.id = p.payment_method_id - WHERE p.order_id = ? AND pm.type = ? AND p.status = ?`, - orderID, constants.PaymentMethodTypePoint, entities.PaymentTransactionStatusCompleted). - Scan(&total).Error - if err != nil { - return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err) - } - return total, nil -} - -func (r *earningRepository) ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error) { - cursorAt, cursorID := since, uuid.Nil - if after != nil { - cursorAt, cursorID = after.UpdatedAt, after.ID - } - var rows []struct { - ID string - UpdatedAt time.Time - } - // An outlet that has neither currency switched on can never earn, so its orders are - // not candidates; otherwise every order of such an outlet would be rescanned on - // every run. - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT o.id::text AS id, o.updated_at - FROM orders o - JOIN customers c ON c.id = o.customer_id - WHERE o.payment_status = ? - AND COALESCE(o.is_void, false) = false - AND c.is_default = false AND c.is_active = true - AND o.updated_at >= ? - AND (o.updated_at, o.id) > (?, ?) - AND EXISTS ( - SELECT 1 FROM outlet_settings s - WHERE s.outlet_id = o.outlet_id - AND s.key IN (?, ?) - AND lower(trim(s.value)) IN ('true', 't', '1') - ) - AND NOT EXISTS ( - SELECT 1 FROM wallet_transactions t - WHERE t.reference_type = ? AND t.reference_id = o.id AND t.type = ? - ) - ORDER BY o.updated_at, o.id - LIMIT ?`, - entities.PaymentStatusCompleted, since, cursorAt, cursorID, - constants.LoyaltyPointEnabledKey, constants.LoyaltyCoinEnabledKey, - constants.WalletRefTypeOrder, constants.WalletTxTypeEarn, limit). - Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to list paid orders without earning: %w", err) - } - out := make([]EarningCursor, 0, len(rows)) - for _, row := range rows { - if id, err := uuid.Parse(row.ID); err == nil { - out = append(out, EarningCursor{UpdatedAt: row.UpdatedAt, ID: id}) - } - } - return out, nil -} - -func (r *earningRepository) ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error) { - var rows []entities.WalletTransaction - err := DBFromContext(ctx, r.db).WithContext(ctx). - Where("reference_type = ? AND reference_id = ? AND type = ?", constants.WalletRefTypeOrder, orderID, constants.WalletTxTypeEarn). - Order("currency"). - Find(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to list EARN rows: %w", err) - } - return rows, nil -} - -func (r *earningRepository) ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error) { - var total int64 - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT COALESCE(SUM(COALESCE((metadata->>'requested_amount')::bigint, -amount)), 0) - FROM wallet_transactions - WHERE reverses_transaction_id = ? AND type = ?`, - earnTransactionID, constants.WalletTxTypeEarnReversal).Scan(&total).Error - if err != nil { - return 0, fmt.Errorf("failed to sum earning reversals: %w", err) - } - return total, nil -} - -func (r *earningRepository) LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error) { - var ids []string - err := DBFromContext(ctx, r.db).WithContext(ctx). - Table("wallet_lots"). - Where("source_transaction_id = ?", transactionID). - Order("expires_at NULLS LAST, created_at, id"). - Pluck("id::text", &ids).Error - if err != nil { - return nil, fmt.Errorf("failed to list lots of a credit: %w", err) - } - out := make([]uuid.UUID, 0, len(ids)) - for _, s := range ids { - if id, err := uuid.Parse(s); err == nil { - out = append(out, id) - } - } - return out, nil -} - -func (r *earningRepository) EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]map[string]int64, error) { - earned := map[uuid.UUID]map[string]int64{} - if len(orderIDs) == 0 { - return earned, nil - } - var rows []struct { - OrderID string - Currency string - Amount int64 - } - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT reference_id::text AS order_id, currency, SUM(amount) AS amount - FROM wallet_transactions - WHERE reference_type = ? AND type = ? AND reference_id IN ? - GROUP BY reference_id, currency`, - constants.WalletRefTypeOrder, constants.WalletTxTypeEarn, orderIDs).Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to sum earnings of orders: %w", err) - } - for _, row := range rows { - id, err := uuid.Parse(row.OrderID) - if err != nil { - continue - } - if earned[id] == nil { - earned[id] = map[string]int64{} - } - earned[id][row.Currency] = row.Amount - } - return earned, nil -} diff --git a/internal/repository/game_play_repository.go b/internal/repository/game_play_repository.go index 48c8d01..b3ffb2c 100644 --- a/internal/repository/game_play_repository.go +++ b/internal/repository/game_play_repository.go @@ -31,7 +31,7 @@ func NewGamePlayRepository(db *gorm.DB) GamePlayRepository { } func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.GamePlay) error { - if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePlay).Error; err != nil { + if err := r.db.WithContext(ctx).Create(gamePlay).Error; err != nil { return fmt.Errorf("failed to create game play: %w", err) } return nil @@ -39,7 +39,7 @@ func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.Game func (r *gamePlayRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePlay, error) { var gamePlay entities.GamePlay - err := DBFromContext(ctx, r.db).WithContext(ctx). + err := r.db.WithContext(ctx). Preload("Game"). Preload("Customer"). Preload("Prize"). @@ -57,7 +57,7 @@ func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search var gamePlays []*entities.GamePlay var total int64 - query := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePlay{}) + query := r.db.WithContext(ctx).Model(&entities.GamePlay{}) // Apply filters if search != "" { @@ -107,7 +107,7 @@ func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID, limit int) ([]*entities.GamePlay, error) { var gamePlays []*entities.GamePlay - query := DBFromContext(ctx, r.db).WithContext(ctx). + query := r.db.WithContext(ctx). Where("customer_id = ?", customerID). Preload("Game"). Preload("Prize"). @@ -127,7 +127,7 @@ func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uui func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID, limit int) ([]*entities.GamePlay, error) { var gamePlays []*entities.GamePlay - query := DBFromContext(ctx, r.db).WithContext(ctx). + query := r.db.WithContext(ctx). Where("game_id = ?", gameID). Preload("Customer"). Preload("Prize"). @@ -147,7 +147,7 @@ func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID, func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID uuid.UUID) (int64, error) { var count int64 - err := DBFromContext(ctx, r.db).WithContext(ctx). + err := r.db.WithContext(ctx). Model(&entities.GamePlay{}). Where("customer_id = ?", customerID). Count(&count).Error @@ -159,7 +159,7 @@ func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID u func (r *gamePlayRepository) CountByGameID(ctx context.Context, gameID uuid.UUID) (int64, error) { var count int64 - err := DBFromContext(ctx, r.db).WithContext(ctx). + err := r.db.WithContext(ctx). Model(&entities.GamePlay{}). Where("game_id = ?", gameID). Count(&count).Error diff --git a/internal/repository/game_prize_repository.go b/internal/repository/game_prize_repository.go index 2b2f13c..243bf73 100644 --- a/internal/repository/game_prize_repository.go +++ b/internal/repository/game_prize_repository.go @@ -3,16 +3,12 @@ package repository import ( "apskel-pos-be/internal/entities" "context" - "errors" "fmt" "github.com/google/uuid" "gorm.io/gorm" ) -// ErrGamePrizeOutOfStock means a prize had less stock than was taken from it. -var ErrGamePrizeOutOfStock = errors.New("game prize is out of stock") - type GamePrizeRepository struct { db *gorm.DB } @@ -22,12 +18,12 @@ func NewGamePrizeRepository(db *gorm.DB) *GamePrizeRepository { } func (r *GamePrizeRepository) Create(ctx context.Context, gamePrize *entities.GamePrize) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePrize).Error + return r.db.WithContext(ctx).Create(gamePrize).Error } func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePrize, error) { var gamePrize entities.GamePrize - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("id = ?", id).First(&gamePrize).Error + err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("id = ?", id).First(&gamePrize).Error if err != nil { return nil, err } @@ -36,7 +32,7 @@ func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entit func (r *GamePrizeRepository) GetByGameID(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) { var gamePrizes []entities.GamePrize - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("game_id = ?", gameID).Find(&gamePrizes).Error + err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("game_id = ?", gameID).Find(&gamePrizes).Error if err != nil { return nil, err } @@ -47,7 +43,7 @@ func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, searc var gamePrizes []entities.GamePrize var total int64 - query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize") + query := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize") if search != "" { searchTerm := "%" + search + "%" @@ -80,31 +76,22 @@ func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, searc } func (r *GamePrizeRepository) Update(ctx context.Context, gamePrize *entities.GamePrize) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Save(gamePrize).Error + return r.db.WithContext(ctx).Save(gamePrize).Error } func (r *GamePrizeRepository) Delete(ctx context.Context, id uuid.UUID) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.GamePrize{}, id).Error + return r.db.WithContext(ctx).Delete(&entities.GamePrize{}, id).Error } -// DecreaseStock takes amount from a prize's stock. When less than amount is left it -// changes nothing and returns ErrGamePrizeOutOfStock. func (r *GamePrizeRepository) DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error { - res := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePrize{}). + return r.db.WithContext(ctx).Model(&entities.GamePrize{}). Where("id = ? AND stock >= ?", id, amount). - Update("stock", gorm.Expr("stock - ?", amount)) - if res.Error != nil { - return fmt.Errorf("failed to decrease prize stock: %w", res.Error) - } - if res.RowsAffected == 0 { - return ErrGamePrizeOutOfStock - } - return nil + Update("stock", gorm.Expr("stock - ?", amount)).Error } func (r *GamePrizeRepository) GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) { var gamePrizes []entities.GamePrize - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize"). + err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize"). Where("game_id = ? AND stock > 0", gameID). Order("weight DESC"). Find(&gamePrizes).Error diff --git a/internal/repository/game_repository.go b/internal/repository/game_repository.go index 22d281a..4d41f18 100644 --- a/internal/repository/game_repository.go +++ b/internal/repository/game_repository.go @@ -18,12 +18,12 @@ func NewGameRepository(db *gorm.DB) *GameRepository { } func (r *GameRepository) Create(ctx context.Context, game *entities.Game) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Create(game).Error + return r.db.WithContext(ctx).Create(game).Error } func (r *GameRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error) { var game entities.Game - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("id = ?", id).First(&game).Error + err := r.db.WithContext(ctx).Preload("Prizes").Where("id = ?", id).First(&game).Error if err != nil { return nil, err } @@ -34,7 +34,7 @@ func (r *GameRepository) List(ctx context.Context, offset, limit int, search, ga var games []entities.Game var total int64 - query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes") + query := r.db.WithContext(ctx).Preload("Prizes") if search != "" { searchTerm := "%" + search + "%" @@ -71,16 +71,16 @@ func (r *GameRepository) List(ctx context.Context, offset, limit int, search, ga } func (r *GameRepository) Update(ctx context.Context, game *entities.Game) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Save(game).Error + return r.db.WithContext(ctx).Save(game).Error } func (r *GameRepository) Delete(ctx context.Context, id uuid.UUID) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.Game{}, id).Error + return r.db.WithContext(ctx).Delete(&entities.Game{}, id).Error } func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, error) { var games []entities.Game - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("is_active = ?", true).Find(&games).Error + err := r.db.WithContext(ctx).Preload("Prizes").Where("is_active = ?", true).Find(&games).Error if err != nil { return nil, err } @@ -89,7 +89,7 @@ func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, e func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Game, error) { var games []entities.Game - err := DBFromContext(ctx, r.db).WithContext(ctx). + err := r.db.WithContext(ctx). Preload("Prizes"). Where("is_active = ? AND type = ?", true, entities.GameTypeSpin). Find(&games).Error @@ -101,7 +101,7 @@ func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Gam func (r *GameRepository) GetFerrisWheelGame(ctx context.Context) (*entities.Game, error) { var game entities.Game - err := DBFromContext(ctx, r.db).WithContext(ctx). + err := r.db.WithContext(ctx). Preload("Prizes"). Where("is_active = ? AND type = ?", true, "SPIN"). First(&game).Error diff --git a/internal/repository/loyalty_settings_repository.go b/internal/repository/loyalty_settings_repository.go deleted file mode 100644 index 757634f..0000000 --- a/internal/repository/loyalty_settings_repository.go +++ /dev/null @@ -1,219 +0,0 @@ -package repository - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" - - "apskel-pos-be/internal/entities" -) - -// ErrLoyaltyOutletNotFound means the outlet does not exist or belongs to another -// organization. -var ErrLoyaltyOutletNotFound = errors.New("loyalty settings: outlet not found") - -// LoyaltySettingChangeFilter narrows the loyalty settings history. -type LoyaltySettingChangeFilter struct { - OrganizationID uuid.UUID - // Nil for the whole organization, outlets included. - OutletID *uuid.UUID - Offset int - Limit int -} - -// LoyaltySettingsRepository stores the raw loyalty settings (the loyalty.* keys of -// outlet_settings and organization_settings) and their history -// (docs/prd-point-coin.md F1, F2). The loyalty settings processor turns them into -// typed values and is the only writer. -type LoyaltySettingsRepository interface { - // GetOutletValues returns the stored loyalty keys of an outlet. A key with no row, - // or a NULL value, is absent. - GetOutletValues(ctx context.Context, outletID uuid.UUID) (map[string]string, error) - GetOrganizationValues(ctx context.Context, organizationID uuid.UUID) (map[string]string, error) - // OutletOrganizationID returns ErrLoyaltyOutletNotFound for an unknown outlet. - OutletOrganizationID(ctx context.Context, outletID uuid.UUID) (uuid.UUID, error) - - // LockScope serializes writers of one outlet's or one organization's settings - // until the transaction ends, so two saves at once cannot both compute their - // changes from the same old values. - LockScope(ctx context.Context, scopeID uuid.UUID) error - // SetOutletValue stores a value, or removes the key when value is nil so it falls - // back to its default. - SetOutletValue(ctx context.Context, outletID uuid.UUID, key string, value *string) error - SetOrganizationValue(ctx context.Context, organizationID uuid.UUID, key string, value *string) error - InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error - // ListChanges returns a page of history, newest first, and the total count. - ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) - - // UndatedBalance counts an organization's lots of one currency that still hold - // something and have no expiry, and what they hold. - UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (lots, amount int64, err error) - // DateUndatedLots gives those lots an expiry, when expiry is turned on (F12). - DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (lots, amount int64, err error) -} - -type loyaltySettingsRepository struct { - db *gorm.DB -} - -func NewLoyaltySettingsRepository(db *gorm.DB) LoyaltySettingsRepository { - return &loyaltySettingsRepository{db: db} -} - -// requireTx returns the caller's transaction. Writes must run in one, together with -// the history rows they produce. -func (r *loyaltySettingsRepository) requireTx(ctx context.Context) (*gorm.DB, error) { - if tx, ok := ctx.Value(txKey).(*gorm.DB); ok && tx != nil { - return tx.WithContext(ctx), nil - } - return nil, errors.New("loyalty settings: write must run inside a transaction") -} - -func (r *loyaltySettingsRepository) values(ctx context.Context, table, scopeColumn string, scopeID uuid.UUID) (map[string]string, error) { - var rows []struct { - Key string - Value string - } - err := DBFromContext(ctx, r.db).WithContext(ctx). - Table(table). - Select("key, value"). - Where(scopeColumn+" = ? AND key LIKE 'loyalty.%' AND value IS NOT NULL", scopeID). - Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to read %s: %w", table, err) - } - values := make(map[string]string, len(rows)) - for _, row := range rows { - values[row.Key] = row.Value - } - return values, nil -} - -func (r *loyaltySettingsRepository) GetOutletValues(ctx context.Context, outletID uuid.UUID) (map[string]string, error) { - return r.values(ctx, "outlet_settings", "outlet_id", outletID) -} - -func (r *loyaltySettingsRepository) GetOrganizationValues(ctx context.Context, organizationID uuid.UUID) (map[string]string, error) { - return r.values(ctx, "organization_settings", "organization_id", organizationID) -} - -func (r *loyaltySettingsRepository) OutletOrganizationID(ctx context.Context, outletID uuid.UUID) (uuid.UUID, error) { - var ids []string - err := DBFromContext(ctx, r.db).WithContext(ctx). - Table("outlets"). - Where("id = ?", outletID). - Limit(1). - Pluck("organization_id::text", &ids).Error - if err != nil { - return uuid.Nil, fmt.Errorf("failed to get outlet organization: %w", err) - } - if len(ids) == 0 { - return uuid.Nil, ErrLoyaltyOutletNotFound - } - return uuid.Parse(ids[0]) -} - -func (r *loyaltySettingsRepository) LockScope(ctx context.Context, scopeID uuid.UUID) error { - db, err := r.requireTx(ctx) - if err != nil { - return err - } - return db.Exec(`SELECT pg_advisory_xact_lock(hashtextextended(?, 0))`, "loyalty-settings:"+scopeID.String()).Error -} - -func (r *loyaltySettingsRepository) SetOutletValue(ctx context.Context, outletID uuid.UUID, key string, value *string) error { - return r.setValue(ctx, "outlet_settings", "outlet_id", outletID, key, value) -} - -func (r *loyaltySettingsRepository) SetOrganizationValue(ctx context.Context, organizationID uuid.UUID, key string, value *string) error { - return r.setValue(ctx, "organization_settings", "organization_id", organizationID, key, value) -} - -// setValue upserts or deletes one key. table and scopeColumn are never user input. -func (r *loyaltySettingsRepository) setValue(ctx context.Context, table, scopeColumn string, scopeID uuid.UUID, key string, value *string) error { - db, err := r.requireTx(ctx) - if err != nil { - return err - } - if value == nil { - err = db.Exec(`DELETE FROM `+table+` WHERE `+scopeColumn+` = ? AND key = ?`, scopeID, key).Error - } else { - err = db.Exec(`INSERT INTO `+table+` (`+scopeColumn+`, key, value) VALUES (?, ?, ?) - ON CONFLICT (`+scopeColumn+`, key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, - scopeID, key, *value).Error - } - if err != nil { - return fmt.Errorf("failed to store %s in %s: %w", key, table, err) - } - return nil -} - -func (r *loyaltySettingsRepository) InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error { - if len(changes) == 0 { - return nil - } - db, err := r.requireTx(ctx) - if err != nil { - return err - } - return db.Create(&changes).Error -} - -func (r *loyaltySettingsRepository) ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) { - query := DBFromContext(ctx, r.db).WithContext(ctx). - Model(&entities.LoyaltySettingChange{}). - Where("organization_id = ?", filter.OrganizationID) - if filter.OutletID != nil { - query = query.Where("outlet_id = ?", *filter.OutletID) - } - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count loyalty setting changes: %w", err) - } - var changes []entities.LoyaltySettingChange - err := query.Order("created_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&changes).Error - if err != nil { - return nil, 0, fmt.Errorf("failed to list loyalty setting changes: %w", err) - } - return changes, total, nil -} - -func (r *loyaltySettingsRepository) UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (int64, int64, error) { - var row struct{ Lots, Amount int64 } - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT COUNT(*) AS lots, COALESCE(SUM(remaining_amount), 0) AS amount - FROM wallet_lots - WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0`, - organizationID, currency).Scan(&row).Error - if err != nil { - return 0, 0, fmt.Errorf("failed to sum undated wallet lots: %w", err) - } - return row.Lots, row.Amount, nil -} - -func (r *loyaltySettingsRepository) DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) { - db, err := r.requireTx(ctx) - if err != nil { - return 0, 0, err - } - var rows []struct{ RemainingAmount int64 } - // Only lots that still hold something: an empty lot never expires anything, and - // leaving it undated keeps the history as it was. - err = db.Raw(` - UPDATE wallet_lots SET expires_at = ? - WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0 - RETURNING remaining_amount`, - expiresAt, organizationID, currency).Scan(&rows).Error - if err != nil { - return 0, 0, fmt.Errorf("failed to date wallet lots: %w", err) - } - var amount int64 - for _, row := range rows { - amount += row.RemainingAmount - } - return int64(len(rows)), amount, nil -} diff --git a/internal/repository/loyalty_settings_repository_test.go b/internal/repository/loyalty_settings_repository_test.go deleted file mode 100644 index 233c66d..0000000 --- a/internal/repository/loyalty_settings_repository_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package repository_test - -import ( - "context" - "os" - "testing" - "time" - - "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" - applogger "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" -) - -// Reads and writes loyalty settings through the processor against Postgres. Needs -// TEST_DATABASE_URL pointing at a migrated database; see wallet_repository_test.go. -func TestLoyaltySettings_AgainstPostgres(t *testing.T) { - dsn := os.Getenv("TEST_DATABASE_URL") - if dsn == "" { - t.Skip("TEST_DATABASE_URL not set") - } - applogger.Setup("fatal", "json") - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) - require.NoError(t, err) - ctx := context.Background() - - org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New() - 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 (?, 'loyalty test', 'basic'), (?, 'other', 'basic')`, org, otherOrg) - exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org) - // A setting that is not a loyalty setting must be left alone. - exec(`INSERT INTO outlet_settings (outlet_id, key, value) VALUES (?, 'receipt.footer', 'Terima kasih')`, outlet) - t.Cleanup(func() { - db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg}) - db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet) - db.Exec(`DELETE FROM organization_settings WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg}) - db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet) - db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg}) - }) - - repo := repository.NewLoyaltySettingsRepository(db) - p := processor.NewLoyaltySettingsProcessor(repo, repository.NewTxManager(db)) - - // No settings: every PRD default. - settings, err := p.Outlet(ctx, outlet) - require.NoError(t, err) - assert.False(t, settings.Point.Enabled) - assert.Equal(t, int64(100), settings.Point.EarnPerAmount) - assert.Equal(t, int64(25000), settings.Coin.EarnPerAmount) - assert.Nil(t, settings.Point.MaxPerOrder) - assert.Equal(t, int64(100), settings.PointPayment.MaxPaymentPercent) - - // Change three keys. - settings.Point.Enabled = true - settings.Point.EarnPerAmount = 1000 - settings.Coin.MaxPerOrder = ptrInt64(5) - changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings) - require.NoError(t, err) - assert.Len(t, changes, 3) - - var stored []struct{ Key, Value string } - require.NoError(t, db.Raw(`SELECT key, value FROM outlet_settings WHERE outlet_id = ? ORDER BY key`, outlet).Scan(&stored).Error) - assert.Equal(t, []struct{ Key, Value string }{ - {constants.LoyaltyCoinMaxPerOrderKey, "5"}, - {constants.LoyaltyPointEarnPerAmountKey, "1000"}, - {constants.LoyaltyPointEnabledKey, "true"}, - {"receipt.footer", "Terima kasih"}, - }, stored, "only the changed keys are stored, next to the untouched non-loyalty key") - - history, err := p.ListChanges(ctx, org, &outlet, 1, 20) - require.NoError(t, err) - require.Len(t, history.Data, 3) - for _, c := range history.Data { - assert.Equal(t, admin, c.ChangedBy) - assert.Nil(t, c.OldValue, "%s was on its default", c.Key) - assert.NotNil(t, c.NewValue) - } - - // A second change records the old value. - settings.Point.EarnPerAmount = 500 - changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *settings) - require.NoError(t, err) - require.Len(t, changes, 1) - assert.Equal(t, "1000", *changes[0].OldValue) - assert.Equal(t, "500", *changes[0].NewValue) - - // Another organization cannot change this outlet. - _, err = p.UpdateOutlet(ctx, otherOrg, outlet, admin, *settings) - assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound) - - // Writing outside a transaction is refused. - assert.Error(t, repo.SetOutletValue(ctx, outlet, constants.LoyaltyPointEnabledKey, nil)) - - // A save must wait while another save of the same organization holds the lock, so - // two admins saving at once cannot both compute their change from the same old - // value. Hold the lock in another transaction and check the save blocks until it - // is released. - txm := repository.NewTxManager(db) - holding, release, holderDone := make(chan struct{}), make(chan struct{}), make(chan error, 1) - go func() { - holderDone <- txm.WithTransaction(ctx, func(ctx context.Context) error { - if err := repo.LockScope(ctx, org); err != nil { - return err - } - close(holding) - <-release - return nil - }) - }() - <-holding - - orgSettings, err := p.Organization(ctx, org) - require.NoError(t, err) - orgSettings.PointValue = 200 - saved := make(chan error, 1) - go func() { - _, _, err := p.UpdateOrganization(ctx, org, admin, *orgSettings) - saved <- err - }() - select { - case err := <-saved: - t.Fatalf("the save finished while another transaction held the lock (err: %v)", err) - case <-time.After(300 * time.Millisecond): - } - close(release) - require.NoError(t, <-holderDone) - require.NoError(t, <-saved) - - value, err := p.PointValue(ctx, org) - require.NoError(t, err) - assert.Equal(t, int64(200), value) - - // The whole organization's history includes the outlet's changes. - all, err := p.ListChanges(ctx, org, nil, 1, 100) - require.NoError(t, err) - assert.EqualValues(t, 5, all.Pagination.Total, "four outlet changes and one organization change") -} - -func ptrInt64(v int64) *int64 { return &v } diff --git a/internal/repository/payment_code_repository.go b/internal/repository/payment_code_repository.go deleted file mode 100644 index f76fc5b..0000000 --- a/internal/repository/payment_code_repository.go +++ /dev/null @@ -1,103 +0,0 @@ -package repository - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - "github.com/redis/go-redis/v9" -) - -var ( - // ErrPaymentCodeTaken means the code is already live for someone; draw another. - ErrPaymentCodeTaken = errors.New("payment code already in use") - // ErrPaymentCodeNotFound means the code does not exist: never issued, expired, or - // already used. - ErrPaymentCodeNotFound = errors.New("payment code not found") - // ErrPaymentCodeWrongCustomer means the code belongs to another customer. - ErrPaymentCodeWrongCustomer = errors.New("payment code belongs to another customer") -) - -// PaymentCodeRepository keeps one-time EnakPoint payment codes in Redis -// (docs/prd-point-coin.md F9). A code expires by TTL and is removed when used. -type PaymentCodeRepository interface { - // Save stores a code for a customer for ttl, and retires the customer's previous - // code so only the newest one works. ErrPaymentCodeTaken if the code is live. - Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error - // Consume uses a code up if it belongs to the customer. A code of another customer - // is left in place, so a cashier scanning it against the wrong order does not burn - // it for its owner. - Consume(ctx context.Context, code string, customerID uuid.UUID) error -} - -type paymentCodeRepository struct { - client *redis.Client -} - -func NewPaymentCodeRepository(client *redis.Client) PaymentCodeRepository { - return &paymentCodeRepository{client: client} -} - -func paymentCodeKey(code string) string { return "wallet:paycode:" + code } - -func paymentCodeCustomerKey(customerID uuid.UUID) string { - return "wallet:paycode:customer:" + customerID.String() -} - -func (r *paymentCodeRepository) Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error { - ok, err := r.client.SetNX(ctx, paymentCodeKey(code), customerID.String(), ttl).Result() - if err != nil { - return fmt.Errorf("failed to store payment code: %w", err) - } - if !ok { - return ErrPaymentCodeTaken - } - previous, err := r.client.GetSet(ctx, paymentCodeCustomerKey(customerID), code).Result() - if err != nil && !errors.Is(err, redis.Nil) { - return fmt.Errorf("failed to track payment code: %w", err) - } - r.client.Expire(ctx, paymentCodeCustomerKey(customerID), ttl) - if previous != "" && previous != code { - // Only if it is still that customer's: the number may have been reissued. - if err := r.compareAndDelete(ctx, previous, customerID); err != nil && !errors.Is(err, ErrPaymentCodeNotFound) && !errors.Is(err, ErrPaymentCodeWrongCustomer) { - return err - } - } - return nil -} - -// consumeScript deletes a code only if it belongs to the given customer, in one step. -// Returns 1 when used up, 0 when missing, -1 when it belongs to someone else. -var consumeScript = redis.NewScript(` -local owner = redis.call('GET', KEYS[1]) -if not owner then return 0 end -if owner ~= ARGV[1] then return -1 end -redis.call('DEL', KEYS[1]) -return 1 -`) - -func (r *paymentCodeRepository) Consume(ctx context.Context, code string, customerID uuid.UUID) error { - code = strings.TrimSpace(code) - if code == "" { - return ErrPaymentCodeNotFound - } - return r.compareAndDelete(ctx, code, customerID) -} - -func (r *paymentCodeRepository) compareAndDelete(ctx context.Context, code string, customerID uuid.UUID) error { - result, err := consumeScript.Run(ctx, r.client, []string{paymentCodeKey(code)}, customerID.String()).Int() - if err != nil { - return fmt.Errorf("failed to use payment code: %w", err) - } - switch result { - case 1: - return nil - case -1: - return ErrPaymentCodeWrongCustomer - default: - return ErrPaymentCodeNotFound - } -} diff --git a/internal/repository/payment_method_repository.go b/internal/repository/payment_method_repository.go index 7a9cd18..41977b7 100644 --- a/internal/repository/payment_method_repository.go +++ b/internal/repository/payment_method_repository.go @@ -75,8 +75,6 @@ func (r *PaymentMethodRepositoryImpl) List(ctx context.Context, filters map[stri case "search": searchValue := "%" + value.(string) + "%" query = query.Where("name ILIKE ? OR processor ILIKE ?", searchValue, searchValue) - case "exclude_type": - query = query.Where("type <> ?", value) default: query = query.Where(key+" = ?", value) } diff --git a/internal/repository/point_payment_repository.go b/internal/repository/point_payment_repository.go deleted file mode 100644 index 43508bf..0000000 --- a/internal/repository/point_payment_repository.go +++ /dev/null @@ -1,339 +0,0 @@ -package repository - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" -) - -// ErrPointPaymentOrderNotFound means the order does not exist. -var ErrPointPaymentOrderNotFound = errors.New("point payment: order not found") - -// PointPaymentOrder is what paying with EnakPoint needs to know about an order. -type PointPaymentOrder struct { - ID uuid.UUID - OrganizationID uuid.UUID - OutletID uuid.UUID - OrderNumber string - OutletName string - CustomerID *uuid.UUID - TotalAmount float64 - PaymentStatus string - IsVoid bool - CustomerIsDefault *bool - CustomerIsActive *bool -} - -// PointPaymentRepository reads and writes what paying an order with EnakPoint touches -// (docs/prd-point-coin.md F9). Unlike the order and payment repositories, every method -// joins the caller's transaction, since the payment row, the balance and the order -// must change together. -type PointPaymentRepository interface { - // GetOrder reads the order; with lock it also locks the order row for the rest of - // the transaction, so two payments of the same order queue up. - GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error) - // GetPaymentMethod returns a method's organization and type. - GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (organizationID uuid.UUID, methodType string, err error) - // TotalPaid sums the order's completed payments, as the rest of the order flow does. - TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error) - // PaidWithPoints sums the rupiah of the order's completed EnakPoint payments. - PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error) - // PointMethodID returns the organization's EnakPoint payment method. - PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) - InsertPayment(ctx context.Context, payment *entities.Payment) error - // UpdateOrderAfterPayment stores what is left to pay and marks the order paid when - // nothing is. - UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error - - // ListPointPayments returns the order's EnakPoint payments with their PAYMENT rows. - ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error) - // PaymentAllocations returns the lots a PAYMENT row took from, longest-lasting first. - PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error) - // RefundedByOriginLot sums, per original lot, what PAYMENT_REFUND rows have already - // returned for a PAYMENT row. - RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error) - // RefundableByOtherMethods is what the order's non-EnakPoint payments can still give - // back: paid minus already refunded. - RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error) -} - -type pointPaymentRepository struct { - db *gorm.DB -} - -func NewPointPaymentRepository(db *gorm.DB) PointPaymentRepository { - return &pointPaymentRepository{db: db} -} - -func (r *pointPaymentRepository) GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error) { - lockClause := "" - if lock { - lockClause = "FOR UPDATE OF o" - } - var rows []struct { - ID string - OrganizationID string - OutletID string - OrderNumber string - OutletName string - CustomerID *string - TotalAmount float64 - PaymentStatus string - IsVoid bool - CustomerIsDefault *bool - CustomerIsActive *bool - } - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id, - o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id, - o.total_amount, o.payment_status, COALESCE(o.is_void, false) AS is_void, - c.is_default AS customer_is_default, c.is_active AS customer_is_active - FROM orders o - LEFT JOIN outlets ou ON ou.id = o.outlet_id - LEFT JOIN customers c ON c.id = o.customer_id - WHERE o.id = ? - `+lockClause, orderID).Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to read order: %w", err) - } - if len(rows) == 0 { - return nil, ErrPointPaymentOrderNotFound - } - row := rows[0] - order := &PointPaymentOrder{ - OrderNumber: row.OrderNumber, - OutletName: row.OutletName, - TotalAmount: row.TotalAmount, - PaymentStatus: row.PaymentStatus, - IsVoid: row.IsVoid, - CustomerIsDefault: row.CustomerIsDefault, - CustomerIsActive: row.CustomerIsActive, - } - order.ID, _ = uuid.Parse(row.ID) - order.OrganizationID, _ = uuid.Parse(row.OrganizationID) - order.OutletID, _ = uuid.Parse(row.OutletID) - if row.CustomerID != nil { - if id, err := uuid.Parse(*row.CustomerID); err == nil { - order.CustomerID = &id - } - } - return order, nil -} - -func (r *pointPaymentRepository) GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (uuid.UUID, string, error) { - var rows []struct { - OrganizationID string - Type string - } - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT organization_id::text AS organization_id, type FROM payment_methods WHERE id = ?`, methodID).Scan(&rows).Error - if err != nil { - return uuid.Nil, "", fmt.Errorf("failed to read payment method: %w", err) - } - if len(rows) == 0 { - return uuid.Nil, "", fmt.Errorf("payment method not found") - } - org, _ := uuid.Parse(rows[0].OrganizationID) - return org, rows[0].Type, nil -} - -func (r *pointPaymentRepository) TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error) { - var total float64 - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT COALESCE(SUM(amount), 0) FROM payments WHERE order_id = ? AND status = ?`, - orderID, entities.PaymentTransactionStatusCompleted).Scan(&total).Error - if err != nil { - return 0, fmt.Errorf("failed to sum payments: %w", err) - } - return total, nil -} - -func (r *pointPaymentRepository) PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error) { - var total float64 - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT COALESCE(SUM(p.amount), 0) FROM payments p - JOIN payment_methods pm ON pm.id = p.payment_method_id - WHERE p.order_id = ? AND p.status = ? AND pm.type = ?`, - orderID, entities.PaymentTransactionStatusCompleted, constants.PaymentMethodTypePoint).Scan(&total).Error - if err != nil { - return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err) - } - return total, nil -} - -func (r *pointPaymentRepository) InsertPayment(ctx context.Context, payment *entities.Payment) error { - if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(payment).Error; err != nil { - return fmt.Errorf("failed to create payment: %w", err) - } - return nil -} - -func (r *pointPaymentRepository) UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error { - paymentStatus := entities.PaymentStatusPartial - if completed { - paymentStatus = entities.PaymentStatusCompleted - } - err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` - UPDATE orders SET remaining_amount = ?, payment_status = ?, - status = CASE WHEN ? THEN ? ELSE status END, updated_at = NOW() - WHERE id = ?`, - remaining, paymentStatus, completed, entities.OrderStatusCompleted, orderID).Error - if err != nil { - return fmt.Errorf("failed to update order after payment: %w", err) - } - return nil -} - -func (r *pointPaymentRepository) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) { - var ids []string - err := DBFromContext(ctx, r.db).WithContext(ctx). - Table("payment_methods"). - Where("organization_id = ? AND type = ?", organizationID, constants.PaymentMethodTypePoint). - Limit(1). - Pluck("id::text", &ids).Error - if err != nil { - return uuid.Nil, fmt.Errorf("failed to find the EnakPoint payment method: %w", err) - } - if len(ids) == 0 { - return uuid.Nil, fmt.Errorf("the organization has no EnakPoint payment method") - } - return uuid.Parse(ids[0]) -} - -// PointPaymentRow is one EnakPoint payment of an order, for refunding it. -type PointPaymentRow struct { - PaymentID uuid.UUID - Status string - PointsUsed int64 - PointValue float64 - RefundAmount float64 - // The PAYMENT ledger row that took the balance; nil if none was written. - LedgerID *uuid.UUID - CustomerID *uuid.UUID - OutletID *uuid.UUID -} - -// PointPaymentAllocation is how much a PAYMENT took from one lot, with that lot's -// expiry, so a refund can return it to the same expiry. -type PointPaymentAllocation struct { - LotID uuid.UUID - Amount int64 - ExpiresAt *time.Time -} - -func (r *pointPaymentRepository) ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error) { - var rows []struct { - PaymentID string - Status string - PointsUsed int64 - PointValue float64 - RefundAmount float64 - LedgerID *string - CustomerID *string - OutletID *string - } - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT p.id::text AS payment_id, p.status, p.points_used, p.point_value, - COALESCE(p.refund_amount, 0) AS refund_amount, - t.id::text AS ledger_id, t.customer_id::text AS customer_id, t.outlet_id::text AS outlet_id - FROM payments p - JOIN payment_methods pm ON pm.id = p.payment_method_id - LEFT JOIN wallet_transactions t ON t.reference_type = ? AND t.reference_id = p.id AND t.type = ? - WHERE p.order_id = ? AND pm.type = ? AND p.points_used IS NOT NULL - ORDER BY p.created_at, p.id`, - constants.WalletRefTypePayment, constants.WalletTxTypePayment, orderID, constants.PaymentMethodTypePoint). - Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to list EnakPoint payments: %w", err) - } - out := make([]PointPaymentRow, 0, len(rows)) - for _, row := range rows { - p := PointPaymentRow{Status: row.Status, PointsUsed: row.PointsUsed, PointValue: row.PointValue, RefundAmount: row.RefundAmount} - p.PaymentID, _ = uuid.Parse(row.PaymentID) - p.LedgerID = parseOptionalUUID(row.LedgerID) - p.CustomerID = parseOptionalUUID(row.CustomerID) - p.OutletID = parseOptionalUUID(row.OutletID) - out = append(out, p) - } - return out, nil -} - -func (r *pointPaymentRepository) PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error) { - var rows []struct { - LotID string - Amount int64 - ExpiresAt *time.Time - } - // Longest-lasting first: a partial refund gives back the balance that keeps longest. - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT a.lot_id::text AS lot_id, a.amount, l.expires_at - FROM wallet_lot_allocations a JOIN wallet_lots l ON l.id = a.lot_id - WHERE a.transaction_id = ? - ORDER BY l.expires_at DESC NULLS FIRST, l.created_at DESC, l.id`, ledgerID).Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to list payment allocations: %w", err) - } - out := make([]PointPaymentAllocation, 0, len(rows)) - for _, row := range rows { - id, err := uuid.Parse(row.LotID) - if err != nil { - continue - } - out = append(out, PointPaymentAllocation{LotID: id, Amount: row.Amount, ExpiresAt: row.ExpiresAt}) - } - return out, nil -} - -func (r *pointPaymentRepository) RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error) { - var rows []struct { - OriginLotID string - Amount int64 - } - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT l.origin_lot_id::text AS origin_lot_id, SUM(l.original_amount) AS amount - FROM wallet_transactions t JOIN wallet_lots l ON l.source_transaction_id = t.id - WHERE t.reverses_transaction_id = ? AND t.type = ? AND l.origin_lot_id IS NOT NULL - GROUP BY l.origin_lot_id`, ledgerID, constants.WalletTxTypePaymentRefund).Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to sum payment refunds: %w", err) - } - out := make(map[uuid.UUID]int64, len(rows)) - for _, row := range rows { - if id, err := uuid.Parse(row.OriginLotID); err == nil { - out[id] = row.Amount - } - } - return out, nil -} - -func (r *pointPaymentRepository) RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error) { - var total float64 - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT COALESCE(SUM(p.amount - COALESCE(p.refund_amount, 0)), 0) - FROM payments p JOIN payment_methods pm ON pm.id = p.payment_method_id - WHERE p.order_id = ? AND pm.type <> ? AND p.status IN (?, ?)`, - orderID, constants.PaymentMethodTypePoint, - entities.PaymentTransactionStatusCompleted, entities.PaymentTransactionStatusRefunded).Scan(&total).Error - if err != nil { - return 0, fmt.Errorf("failed to sum refundable payments: %w", err) - } - return total, nil -} - -func parseOptionalUUID(s *string) *uuid.UUID { - if s == nil { - return nil - } - id, err := uuid.Parse(*s) - if err != nil { - return nil - } - return &id -} diff --git a/internal/repository/tx_manager.go b/internal/repository/tx_manager.go index 33760c2..ee9a3f8 100644 --- a/internal/repository/tx_manager.go +++ b/internal/repository/tx_manager.go @@ -50,10 +50,3 @@ func (m *TxManager) WithTransactionOptions(ctx context.Context, opts *sql.TxOpti return fn(ctxTx) }, opts) } - -// DetachTransaction returns ctx without the caller's transaction, so work started from -// it (such as loyalty earning after a payment) reads committed data and commits on its -// own, whatever happens to the caller's transaction. -func DetachTransaction(ctx context.Context) context.Context { - return context.WithValue(ctx, txKey, (*gorm.DB)(nil)) -} diff --git a/internal/repository/wallet_admin_repository.go b/internal/repository/wallet_admin_repository.go deleted file mode 100644 index f3035c2..0000000 --- a/internal/repository/wallet_admin_repository.go +++ /dev/null @@ -1,126 +0,0 @@ -package repository - -import ( - "context" - "fmt" - - "github.com/google/uuid" - "gorm.io/gorm" - - "apskel-pos-be/internal/entities" -) - -// WalletCustomer is the customer a dashboard wallet screen is about. -type WalletCustomer struct { - ID uuid.UUID - Name string - Phone *string -} - -// WalletAdminRepository serves what the dashboard adds on top of the customer's own -// wallet view (docs/prd-point-coin.md F7): the raw balances, every lot with balance, -// and the real names behind a ledger row. Nothing here changes a balance. -type WalletAdminRepository interface { - // GetCustomer returns ErrWalletNotFound unless the customer exists and belongs to - // the organization, so one organization can never read another's customers. - GetCustomer(ctx context.Context, organizationID, customerID uuid.UUID) (*WalletCustomer, error) - // GetWallet returns nil, nil for a customer who has never had a wallet. - GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) - // ListLotsWithBalance returns every lot that still holds something, expired or - // not, in K9 spending order. - ListLotsWithBalance(ctx context.Context, customerID uuid.UUID) ([]entities.WalletLot, error) - CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) - UserNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) - OutletNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) -} - -type walletAdminRepository struct { - db *gorm.DB -} - -func NewWalletAdminRepository(db *gorm.DB) WalletAdminRepository { - return &walletAdminRepository{db: db} -} - -func (r *walletAdminRepository) GetCustomer(ctx context.Context, organizationID, customerID uuid.UUID) (*WalletCustomer, error) { - var customers []WalletCustomer - err := DBFromContext(ctx, r.db).WithContext(ctx). - Table("customers"). - Select("id, name, COALESCE(phone_number, phone) AS phone"). - Where("id = ? AND organization_id = ?", customerID, organizationID). - Limit(1). - Scan(&customers).Error - if err != nil { - return nil, fmt.Errorf("failed to get customer: %w", err) - } - if len(customers) == 0 { - return nil, ErrWalletNotFound - } - return &customers[0], nil -} - -func (r *walletAdminRepository) GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) { - var wallets []entities.CustomerWallet - err := DBFromContext(ctx, r.db).WithContext(ctx). - Where("customer_id = ?", customerID). - Limit(1). - Find(&wallets).Error - if err != nil { - return nil, fmt.Errorf("failed to get customer wallet: %w", err) - } - if len(wallets) == 0 { - return nil, nil - } - return &wallets[0], nil -} - -func (r *walletAdminRepository) ListLotsWithBalance(ctx context.Context, customerID uuid.UUID) ([]entities.WalletLot, error) { - var lots []entities.WalletLot - err := DBFromContext(ctx, r.db).WithContext(ctx). - Where("customer_id = ? AND remaining_amount > 0", customerID). - Order("currency, expires_at NULLS LAST, created_at, id"). - Find(&lots).Error - if err != nil { - return nil, fmt.Errorf("failed to list wallet lots: %w", err) - } - return lots, nil -} - -func (r *walletAdminRepository) CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) { - return r.names(ctx, "customers", ids) -} - -func (r *walletAdminRepository) UserNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) { - return r.names(ctx, "users", ids) -} - -func (r *walletAdminRepository) OutletNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) { - return r.names(ctx, "outlets", ids) -} - -// names looks up the name column of a table by id. The table name is never taken -// from user input. -func (r *walletAdminRepository) names(ctx context.Context, table string, ids []uuid.UUID) (map[uuid.UUID]string, error) { - names := make(map[uuid.UUID]string, len(ids)) - if len(ids) == 0 { - return names, nil - } - var rows []struct { - ID string - Name string - } - err := DBFromContext(ctx, r.db).WithContext(ctx). - Table(table). - Select("id::text AS id, name"). - Where("id IN ?", ids). - Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to look up %s names: %w", table, err) - } - for _, row := range rows { - if id, err := uuid.Parse(row.ID); err == nil { - names[id] = row.Name - } - } - return names, nil -} diff --git a/internal/repository/wallet_expiry_repository.go b/internal/repository/wallet_expiry_repository.go deleted file mode 100644 index c7e0b99..0000000 --- a/internal/repository/wallet_expiry_repository.go +++ /dev/null @@ -1,118 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -// DueLot is a lot whose expiry has passed and that still holds something. -type DueLot struct { - ID uuid.UUID - CustomerID uuid.UUID - Currency string - Remaining int64 - ExpiresAt time.Time - // The description of the row that created the lot, for the EXPIRE row's. - SourceDescription string -} - -// UpcomingExpiry is how much of a customer's balance expires on one day. -type UpcomingExpiry struct { - CustomerID uuid.UUID - // A calendar date in walletDisplayTimeZone, formatted YYYY-MM-DD. - Date string - Amount int64 -} - -// WalletExpiryRepository finds what the expiry job has to do (docs/prd-point-coin.md -// F12). Balances only change through WalletProcessor. -type WalletExpiryRepository interface { - // ListDueLots returns up to limit lots due at asOf, the longest overdue first. It - // takes no lock: locking a lot before its wallet would deadlock against payments, - // which lock the wallet first. WalletProcessor.ExpireLot locks and reads again. - ListDueLots(ctx context.Context, asOf time.Time, limit int) ([]DueLot, error) - - // OrganizationsWithUpcomingExpiry lists the organizations that have balance - // expiring after asOf. - OrganizationsWithUpcomingExpiry(ctx context.Context, asOf time.Time) ([]uuid.UUID, error) - // UpcomingUnreminded sums, per customer and expiry day, the balance of one currency - // of an organization expiring after asOf and up to until, leaving out the days the - // customer has already been reminded of. - UpcomingUnreminded(ctx context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]UpcomingExpiry, error) - // MarkReminded records a reminder, and reports false when it was already recorded, - // by this run or another. - MarkReminded(ctx context.Context, reminder UpcomingExpiry, currency string) (bool, error) -} - -type walletExpiryRepository struct { - db *gorm.DB -} - -func NewWalletExpiryRepository(db *gorm.DB) WalletExpiryRepository { - return &walletExpiryRepository{db: db} -} - -func (r *walletExpiryRepository) ListDueLots(ctx context.Context, asOf time.Time, limit int) ([]DueLot, error) { - var lots []DueLot - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT l.id, l.customer_id, l.currency, l.remaining_amount AS remaining, l.expires_at, - t.description AS source_description - FROM wallet_lots l - JOIN wallet_transactions t ON t.id = l.source_transaction_id - WHERE l.remaining_amount > 0 AND l.expires_at <= ? - ORDER BY l.expires_at, l.id - LIMIT ?`, asOf, limit).Scan(&lots).Error - if err != nil { - return nil, fmt.Errorf("failed to list due wallet lots: %w", err) - } - return lots, nil -} - -func (r *walletExpiryRepository) OrganizationsWithUpcomingExpiry(ctx context.Context, asOf time.Time) ([]uuid.UUID, error) { - var ids []uuid.UUID - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT DISTINCT organization_id FROM wallet_lots - WHERE remaining_amount > 0 AND expires_at > ?`, asOf).Scan(&ids).Error - if err != nil { - return nil, fmt.Errorf("failed to list organizations with expiring balances: %w", err) - } - return ids, nil -} - -func (r *walletExpiryRepository) UpcomingUnreminded(ctx context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]UpcomingExpiry, error) { - var rows []UpcomingExpiry - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - WITH by_day AS ( - SELECT customer_id, (expires_at AT TIME ZONE ?)::date AS day, SUM(remaining_amount) AS amount - FROM wallet_lots - WHERE organization_id = ? AND currency = ? AND remaining_amount > 0 - AND expires_at > ? AND expires_at <= ? - GROUP BY customer_id, day - ) - SELECT d.customer_id, to_char(d.day, 'YYYY-MM-DD') AS date, d.amount - FROM by_day d - LEFT JOIN wallet_expiry_reminders w - ON w.customer_id = d.customer_id AND w.currency = ? AND w.expiry_date = d.day - WHERE w.customer_id IS NULL - ORDER BY d.day, d.customer_id`, - walletDisplayTimeZone, organizationID, currency, asOf, until, currency).Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to list upcoming expiry: %w", err) - } - return rows, nil -} - -func (r *walletExpiryRepository) MarkReminded(ctx context.Context, reminder UpcomingExpiry, currency string) (bool, error) { - res := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` - INSERT INTO wallet_expiry_reminders (customer_id, currency, expiry_date, amount) - VALUES (?, ?, ?::date, ?) - ON CONFLICT DO NOTHING`, reminder.CustomerID, currency, reminder.Date, reminder.Amount) - if res.Error != nil { - return false, fmt.Errorf("failed to record expiry reminder: %w", res.Error) - } - return res.RowsAffected == 1, nil -} diff --git a/internal/repository/wallet_migration_repository.go b/internal/repository/wallet_migration_repository.go deleted file mode 100644 index cfef523..0000000 --- a/internal/repository/wallet_migration_repository.go +++ /dev/null @@ -1,134 +0,0 @@ -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 -} diff --git a/internal/repository/wallet_move_repository.go b/internal/repository/wallet_move_repository.go deleted file mode 100644 index d8cbb70..0000000 --- a/internal/repository/wallet_move_repository.go +++ /dev/null @@ -1,82 +0,0 @@ -package repository - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" - - "apskel-pos-be/internal/constants" -) - -// WalletMoveCustomer is a customer on either side of an exchange or a transfer. -type WalletMoveCustomer struct { - ID uuid.UUID - OrganizationID uuid.UUID - Name string - PhoneNumber *string - IsDefault bool - IsActive bool -} - -// WalletMoveRepository reads what exchanges and transfers check before moving a -// balance (docs/prd-point-coin.md F4, F5). The balance itself only moves through -// WalletProcessor. -type WalletMoveRepository interface { - // GetCustomer returns ErrWalletNotFound when the customer does not exist. - GetCustomer(ctx context.Context, customerID uuid.UUID) (*WalletMoveCustomer, error) - // FindCustomerByPhone returns ErrWalletNotFound when no customer has the number. - // Phone numbers are unique across organizations, so there is at most one. - FindCustomerByPhone(ctx context.Context, phoneNumber string) (*WalletMoveCustomer, error) - // TransferredOutSince sums what a customer has sent in one currency since a time. - // Call it under the sender's wallet lock, so transfers at the same time count. - TransferredOutSince(ctx context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) -} - -type walletMoveRepository struct { - db *gorm.DB -} - -func NewWalletMoveRepository(db *gorm.DB) WalletMoveRepository { - return &walletMoveRepository{db: db} -} - -func (r *walletMoveRepository) GetCustomer(ctx context.Context, customerID uuid.UUID) (*WalletMoveCustomer, error) { - return r.customer(ctx, "id = ?", customerID) -} - -func (r *walletMoveRepository) FindCustomerByPhone(ctx context.Context, phoneNumber string) (*WalletMoveCustomer, error) { - return r.customer(ctx, "phone_number = ?", phoneNumber) -} - -func (r *walletMoveRepository) TransferredOutSince(ctx context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) { - var total int64 - err := DBFromContext(ctx, r.db).WithContext(ctx). - Table("wallet_transactions"). - Select("COALESCE(SUM(-amount), 0)"). - Where("customer_id = ? AND currency = ? AND type = ? AND created_at >= ?", customerID, currency, constants.WalletTxTypeTransferOut, since). - Scan(&total).Error - if err != nil { - return 0, fmt.Errorf("failed to sum transfers: %w", err) - } - return total, nil -} - -func (r *walletMoveRepository) customer(ctx context.Context, where string, arg interface{}) (*WalletMoveCustomer, error) { - var c WalletMoveCustomer - err := DBFromContext(ctx, r.db).WithContext(ctx). - Table("customers"). - Select("id, organization_id, name, phone_number, is_default, is_active"). - Where(where, arg). - Take(&c).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrWalletNotFound - } - return nil, fmt.Errorf("failed to get customer: %w", err) - } - return &c, nil -} diff --git a/internal/repository/wallet_query_repository.go b/internal/repository/wallet_query_repository.go deleted file mode 100644 index ecd446f..0000000 --- a/internal/repository/wallet_query_repository.go +++ /dev/null @@ -1,202 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" - - "apskel-pos-be/internal/entities" -) - -// walletDisplayTimeZone is the zone expiry dates are grouped in for display, the same -// one the database connection uses (config.Database.DSN). -const walletDisplayTimeZone = "Asia/Jakarta" - -// WalletTransactionFilter narrows a customer's ledger. Zero values mean no filter. -type WalletTransactionFilter struct { - CustomerID uuid.UUID - Currency string - Types []string - // From is inclusive and To is exclusive. - From *time.Time - To *time.Time - Offset int - Limit int -} - -// WalletExpiringAmount is how much of one currency expires on one day. -type WalletExpiringAmount struct { - Currency string - // A calendar date in walletDisplayTimeZone, formatted YYYY-MM-DD. - Date string - Amount int64 -} - -// WalletQueryRepository serves the read side of the wallet: balances, history and -// upcoming expiry for display. Nothing here changes a balance. -type WalletQueryRepository interface { - // GetCustomerOrganizationID returns ErrWalletNotFound when the customer does not exist. - GetCustomerOrganizationID(ctx context.Context, customerID uuid.UUID) (uuid.UUID, error) - // SpendableBalances sums, per currency, the lots that still have balance and have - // not expired at asOf. It differs from the wallet balance only for lots that have - // expired but that the expiry job has not processed yet. - SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) - // NearestExpiring returns, per currency, the earliest day after asOf on which some - // balance expires, and how much expires that day. - NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) - // ExpiringByDay returns, per currency and day, everything that expires after asOf, - // soonest first. - ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) - // ListTransactions returns a page of the ledger, newest first, and the total count. - ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) - // OrganizationOutstanding sums every wallet balance of an organization. - OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (points, coins int64, err error) - // ListLotsBySourceTransactions returns the lots created by the given credits. - ListLotsBySourceTransactions(ctx context.Context, transactionIDs []uuid.UUID) ([]entities.WalletLot, error) -} - -type walletQueryRepository struct { - db *gorm.DB -} - -func NewWalletQueryRepository(db *gorm.DB) WalletQueryRepository { - return &walletQueryRepository{db: db} -} - -func (r *walletQueryRepository) GetCustomerOrganizationID(ctx context.Context, customerID uuid.UUID) (uuid.UUID, error) { - var ids []string - err := DBFromContext(ctx, r.db).WithContext(ctx). - Table("customers"). - Where("id = ?", customerID). - Limit(1). - Pluck("organization_id::text", &ids).Error - if err != nil { - return uuid.Nil, fmt.Errorf("failed to get customer organization: %w", err) - } - if len(ids) == 0 { - return uuid.Nil, ErrWalletNotFound - } - return uuid.Parse(ids[0]) -} - -func (r *walletQueryRepository) SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) { - var rows []struct { - Currency string - Amount int64 - } - err := DBFromContext(ctx, r.db).WithContext(ctx). - Model(&entities.WalletLot{}). - Select("currency, SUM(remaining_amount) AS amount"). - Where("customer_id = ? AND remaining_amount > 0", customerID). - Where("(expires_at IS NULL OR expires_at > ?)", asOf). - Group("currency"). - Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to sum spendable wallet balance: %w", err) - } - balances := make(map[string]int64, len(rows)) - for _, row := range rows { - balances[row.Currency] = row.Amount - } - return balances, nil -} - -func (r *walletQueryRepository) NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) { - var rows []WalletExpiringAmount - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - WITH by_day AS ( - SELECT currency, - (expires_at AT TIME ZONE ?)::date AS day, - SUM(remaining_amount) AS amount - FROM wallet_lots - WHERE customer_id = ? AND remaining_amount > 0 AND expires_at > ? - GROUP BY currency, day - ) - SELECT DISTINCT ON (currency) currency, to_char(day, 'YYYY-MM-DD') AS date, amount - FROM by_day - ORDER BY currency, day`, walletDisplayTimeZone, customerID, asOf). - Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to get nearest expiring wallet balance: %w", err) - } - return rows, nil -} - -func (r *walletQueryRepository) ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) { - query := DBFromContext(ctx, r.db).WithContext(ctx). - Model(&entities.WalletTransaction{}). - Where("customer_id = ?", filter.CustomerID) - if filter.Currency != "" { - query = query.Where("currency = ?", filter.Currency) - } - if len(filter.Types) > 0 { - query = query.Where("type IN ?", filter.Types) - } - if filter.From != nil { - query = query.Where("created_at >= ?", *filter.From) - } - if filter.To != nil { - query = query.Where("created_at < ?", *filter.To) - } - - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count wallet transactions: %w", err) - } - - var transactions []entities.WalletTransaction - err := query. - Order("created_at DESC, id DESC"). - Offset(filter.Offset). - Limit(filter.Limit). - Find(&transactions).Error - if err != nil { - return nil, 0, fmt.Errorf("failed to list wallet transactions: %w", err) - } - return transactions, total, nil -} - -func (r *walletQueryRepository) ListLotsBySourceTransactions(ctx context.Context, transactionIDs []uuid.UUID) ([]entities.WalletLot, error) { - var lots []entities.WalletLot - if len(transactionIDs) == 0 { - return lots, nil - } - err := DBFromContext(ctx, r.db).WithContext(ctx). - Where("source_transaction_id IN ?", transactionIDs). - Order("expires_at NULLS LAST, created_at, id"). - Find(&lots).Error - if err != nil { - return nil, fmt.Errorf("failed to list wallet lots: %w", err) - } - return lots, nil -} - -func (r *walletQueryRepository) OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (int64, int64, error) { - var totals struct{ Points, Coins int64 } - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT COALESCE(SUM(point_balance), 0) AS points, COALESCE(SUM(coin_balance), 0) AS coins - FROM customer_wallets WHERE organization_id = ?`, organizationID).Scan(&totals).Error - if err != nil { - return 0, 0, fmt.Errorf("failed to sum outstanding balances: %w", err) - } - return totals.Points, totals.Coins, nil -} - -func (r *walletQueryRepository) ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) { - var rows []WalletExpiringAmount - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT currency, to_char((expires_at AT TIME ZONE ?)::date, 'YYYY-MM-DD') AS date, - SUM(remaining_amount) AS amount - FROM wallet_lots - WHERE customer_id = ? AND remaining_amount > 0 AND expires_at > ? - GROUP BY currency, date - ORDER BY date, currency`, walletDisplayTimeZone, customerID, asOf). - Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to list expiring wallet balance: %w", err) - } - return rows, nil -} diff --git a/internal/repository/wallet_reconciliation_repository.go b/internal/repository/wallet_reconciliation_repository.go deleted file mode 100644 index af41535..0000000 --- a/internal/repository/wallet_reconciliation_repository.go +++ /dev/null @@ -1,160 +0,0 @@ -package repository - -import ( - "context" - "fmt" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -// The reconciliation checks of docs/prd-point-coin.md §7.5. -const ( - // Wallet balance = SUM(amount) of the customer's ledger rows, per currency. - WalletCheckBalanceVsLedger = "BALANCE_VS_LEDGER" - // Wallet balance = SUM(remaining_amount) of the customer's lots, per currency. - WalletCheckBalanceVsLots = "BALANCE_VS_LOTS" - // For every lot: original_amount - SUM(allocations) = remaining_amount. - WalletCheckLotVsAllocations = "LOT_VS_ALLOCATIONS" - // For every deduction: SUM(allocations) = |amount|. - WalletCheckDebitVsAllocations = "DEBIT_VS_ALLOCATIONS" - // For every addition: SUM(original_amount) of the lots it created = amount. Not - // listed in §7.5, but the engine keeps it and the other checks rely on it. - WalletCheckCreditVsLots = "CREDIT_VS_LOTS" -) - -// WalletDiscrepancy is one place where the wallet tables disagree with each other. -type WalletDiscrepancy struct { - Check string - OrganizationID uuid.UUID - CustomerID uuid.UUID - Currency string - // The lot or ledger row the check is about. Nil for the per-wallet checks. - ObjectID *uuid.UUID - Expected int64 - Actual int64 -} - -// WalletReconciliationRepository runs the §7.5 checks across every wallet. -type WalletReconciliationRepository interface { - // FindDiscrepancies returns every discrepancy, at most limit per check, so one - // systematic bug cannot produce an unbounded report. - FindDiscrepancies(ctx context.Context, limit int) ([]WalletDiscrepancy, error) -} - -type walletReconciliationRepository struct { - db *gorm.DB -} - -func NewWalletReconciliationRepository(db *gorm.DB) WalletReconciliationRepository { - return &walletReconciliationRepository{db: db} -} - -// Each query returns check, organization_id, customer_id, currency, object_id, -// expected and actual. Aggregates are joined rather than correlated, so each check is -// a handful of scans however many customers there are. -var walletReconciliationQueries = []struct { - check string - sql string -}{ - {WalletCheckBalanceVsLedger, ` - WITH ledger AS ( - SELECT customer_id, currency, MAX(organization_id::text) AS organization_id, SUM(amount) AS total - FROM wallet_transactions GROUP BY customer_id, currency - ), balances AS ( - SELECT customer_id, organization_id::text AS organization_id, 'POINT' AS currency, point_balance AS balance FROM customer_wallets - UNION ALL - SELECT customer_id, organization_id::text, 'COIN', coin_balance FROM customer_wallets - ) - SELECT COALESCE(b.organization_id, l.organization_id) AS organization_id, - COALESCE(b.customer_id, l.customer_id)::text AS customer_id, - COALESCE(b.currency, l.currency) AS currency, - NULL AS object_id, - COALESCE(l.total, 0) AS expected, - COALESCE(b.balance, 0) AS actual - FROM balances b - FULL JOIN ledger l ON l.customer_id = b.customer_id AND l.currency = b.currency - WHERE COALESCE(b.balance, 0) <> COALESCE(l.total, 0) - LIMIT ?`}, - {WalletCheckBalanceVsLots, ` - WITH lots AS ( - SELECT customer_id, currency, MAX(organization_id::text) AS organization_id, SUM(remaining_amount) AS total - FROM wallet_lots GROUP BY customer_id, currency - ), balances AS ( - SELECT customer_id, organization_id::text AS organization_id, 'POINT' AS currency, point_balance AS balance FROM customer_wallets - UNION ALL - SELECT customer_id, organization_id::text, 'COIN', coin_balance FROM customer_wallets - ) - SELECT COALESCE(b.organization_id, l.organization_id) AS organization_id, - COALESCE(b.customer_id, l.customer_id)::text AS customer_id, - COALESCE(b.currency, l.currency) AS currency, - NULL AS object_id, - COALESCE(l.total, 0) AS expected, - COALESCE(b.balance, 0) AS actual - FROM balances b - FULL JOIN lots l ON l.customer_id = b.customer_id AND l.currency = b.currency - WHERE COALESCE(b.balance, 0) <> COALESCE(l.total, 0) - LIMIT ?`}, - {WalletCheckLotVsAllocations, ` - SELECT l.organization_id::text AS organization_id, l.customer_id::text AS customer_id, l.currency, - l.id::text AS object_id, - l.original_amount - COALESCE(a.total, 0) AS expected, - l.remaining_amount AS actual - FROM wallet_lots l - LEFT JOIN (SELECT lot_id, SUM(amount) AS total FROM wallet_lot_allocations GROUP BY lot_id) a ON a.lot_id = l.id - WHERE l.original_amount - COALESCE(a.total, 0) <> l.remaining_amount - LIMIT ?`}, - {WalletCheckDebitVsAllocations, ` - SELECT t.organization_id::text AS organization_id, t.customer_id::text AS customer_id, t.currency, - t.id::text AS object_id, - -t.amount AS expected, - COALESCE(a.total, 0) AS actual - FROM wallet_transactions t - LEFT JOIN (SELECT transaction_id, SUM(amount) AS total FROM wallet_lot_allocations GROUP BY transaction_id) a ON a.transaction_id = t.id - WHERE t.amount < 0 AND -t.amount <> COALESCE(a.total, 0) - LIMIT ?`}, - {WalletCheckCreditVsLots, ` - SELECT t.organization_id::text AS organization_id, t.customer_id::text AS customer_id, t.currency, - t.id::text AS object_id, - t.amount AS expected, - COALESCE(l.total, 0) AS actual - FROM wallet_transactions t - LEFT JOIN (SELECT source_transaction_id, SUM(original_amount) AS total FROM wallet_lots GROUP BY source_transaction_id) l ON l.source_transaction_id = t.id - WHERE t.amount > 0 AND t.amount <> COALESCE(l.total, 0) - LIMIT ?`}, -} - -func (r *walletReconciliationRepository) FindDiscrepancies(ctx context.Context, limit int) ([]WalletDiscrepancy, error) { - db := DBFromContext(ctx, r.db).WithContext(ctx) - var found []WalletDiscrepancy - for _, q := range walletReconciliationQueries { - var rows []struct { - OrganizationID string - CustomerID string - Currency string - ObjectID *string - Expected int64 - Actual int64 - } - if err := db.Raw(q.sql, limit).Scan(&rows).Error; err != nil { - return nil, fmt.Errorf("wallet reconciliation check %s failed: %w", q.check, err) - } - for _, row := range rows { - d := WalletDiscrepancy{ - Check: q.check, - Currency: row.Currency, - Expected: row.Expected, - Actual: row.Actual, - } - d.OrganizationID, _ = uuid.Parse(row.OrganizationID) - d.CustomerID, _ = uuid.Parse(row.CustomerID) - if row.ObjectID != nil { - if id, err := uuid.Parse(*row.ObjectID); err == nil { - d.ObjectID = &id - } - } - found = append(found, d) - } - } - return found, nil -} diff --git a/internal/repository/wallet_reconciliation_repository_test.go b/internal/repository/wallet_reconciliation_repository_test.go deleted file mode 100644 index 4b530f4..0000000 --- a/internal/repository/wallet_reconciliation_repository_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package repository_test - -import ( - "context" - "os" - "sort" - "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/processor" - "apskel-pos-be/internal/repository" -) - -// Builds consistent wallets through the engine, checks the reconciliation is silent, -// then breaks each §7.5 invariant for a different customer and checks each break is -// found by the right checks and nothing else is. Needs TEST_DATABASE_URL pointing at -// a migrated database; see wallet_repository_test.go. -func TestWalletReconciliation_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() - names := []string{"clean", "balance", "lot", "allocation", "credit"} - customers := map[string]uuid.UUID{} - var ids []uuid.UUID - require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'recon test', 'basic')`, org).Error) - for _, n := range names { - customers[n] = uuid.New() - ids = append(ids, customers[n]) - require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, ?)`, customers[n], org, n).Error) - } - t.Cleanup(func() { - db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, ids) - db.Exec(`DELETE FROM wallet_lot_allocations WHERE transaction_id IN (SELECT id FROM wallet_transactions WHERE customer_id IN ?)`, ids) - db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, ids) - db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, ids) - db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, ids) - db.Exec(`DELETE FROM customers WHERE id IN ?`, ids) - db.Exec(`DELETE FROM organizations WHERE id = ?`, org) - }) - - // Every customer: +100 in two lots, +30 coins, -70 points across both lots. - wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) - txm := repository.NewTxManager(db) - for _, id := range ids { - require.NoError(t, txm.WithTransaction(ctx, func(ctx context.Context) error { - if _, err := wallet.Credit(ctx, processor.WalletCreditInput{ - WalletEntry: processor.WalletEntry{CustomerID: id, Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypeMigration, Amount: 100, ReferenceType: constants.WalletRefTypeLegacyPoints, - ReferenceID: uuid.New(), Description: "Saldo awal"}, - Lots: []processor.WalletLotInput{{Amount: 60}, {Amount: 40}}, - }); err != nil { - return err - } - if _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ - CustomerID: id, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration, - Amount: 30, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: id, Description: "Saldo awal"}}); err != nil { - return err - } - outlet := uuid.New() - _, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{ - CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, - Amount: 70, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet, - Description: "Bayar"}}) - return err - })) - } - - recon := repository.NewWalletReconciliationRepository(db) - // Other packages' tests may share the database, so only these customers count. - checksByCustomer := func() map[string][]string { - t.Helper() - found, err := recon.FindDiscrepancies(ctx, 1000) - require.NoError(t, err) - byName := map[uuid.UUID]string{} - for n, id := range customers { - byName[id] = n - } - out := map[string][]string{} - for _, d := range found { - if n, ok := byName[d.CustomerID]; ok { - assert.Equal(t, org, d.OrganizationID) - out[n] = append(out[n], d.Check) - } - } - for n := range out { - sort.Strings(out[n]) - } - return out - } - - assert.Empty(t, checksByCustomer(), "consistent data reports nothing") - - exec := func(q string, args ...any) { - t.Helper() - require.NoError(t, db.Exec(q, args...).Error) - } - // A balance moved without a ledger row or a lot. - exec(`UPDATE customer_wallets SET point_balance = point_balance + 5 WHERE customer_id = ?`, customers["balance"]) - // A lot's remainder changed without an allocation. - exec(`UPDATE wallet_lots SET remaining_amount = remaining_amount - 1 - WHERE id = (SELECT id FROM wallet_lots WHERE customer_id = ? AND remaining_amount > 0 LIMIT 1)`, customers["lot"]) - // An allocation lost. - exec(`DELETE FROM wallet_lot_allocations WHERE (transaction_id, lot_id) IN ( - SELECT a.transaction_id, a.lot_id FROM wallet_lot_allocations a - JOIN wallet_transactions t ON t.id = a.transaction_id WHERE t.customer_id = ? LIMIT 1)`, customers["allocation"]) - // A credit whose lot was never written, with the balance moved to match the ledger. - exec(`INSERT INTO wallet_transactions (organization_id, customer_id, currency, type, amount, balance_after, reference_type, reference_id, description) - VALUES (?, ?, 'COIN', 'MIGRATION', 10, 40, 'LEGACY_TOKENS', ?, 'x')`, org, customers["credit"], customers["credit"]) - exec(`UPDATE customer_wallets SET coin_balance = coin_balance + 10 WHERE customer_id = ?`, customers["credit"]) - - assert.Equal(t, map[string][]string{ - "balance": {repository.WalletCheckBalanceVsLedger, repository.WalletCheckBalanceVsLots}, - "lot": {repository.WalletCheckBalanceVsLots, repository.WalletCheckLotVsAllocations}, - "allocation": {repository.WalletCheckDebitVsAllocations, repository.WalletCheckLotVsAllocations}, - "credit": {repository.WalletCheckBalanceVsLots, repository.WalletCheckCreditVsLots}, - }, checksByCustomer(), "each break is found by exactly the checks it violates, and the clean customer by none") - - // The per-check limit caps the report. - found, err := recon.FindDiscrepancies(ctx, 1) - require.NoError(t, err) - perCheck := map[string]int{} - for _, d := range found { - perCheck[d.Check]++ - } - for check, n := range perCheck { - assert.LessOrEqual(t, n, 1, check) - } -} diff --git a/internal/repository/wallet_repository.go b/internal/repository/wallet_repository.go deleted file mode 100644 index be3763b..0000000 --- a/internal/repository/wallet_repository.go +++ /dev/null @@ -1,295 +0,0 @@ -package repository - -import ( - "context" - "errors" - "fmt" - "sort" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" - "gorm.io/gorm/clause" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" -) - -var ( - // ErrWalletTxRequired is returned by every write and lock when the context carries - // no transaction from TxManager. Outside a transaction a lock is released as soon as - // it is taken, and a balance could move without its ledger row. - ErrWalletTxRequired = errors.New("wallet: operation must run inside a transaction") - // ErrWalletNotFound means the customer does not exist, so no wallet could be made. - ErrWalletNotFound = errors.New("wallet: customer not found") - // ErrWalletInsufficientBalance means a conditional update matched no row because - // the balance would have gone negative. - ErrWalletInsufficientBalance = errors.New("wallet: insufficient balance") - // ErrWalletLotInsufficient means a lot had less remaining than was taken from it. - ErrWalletLotInsufficient = errors.New("wallet: lot has insufficient remaining amount") -) - -// WalletRepository reads and writes the wallet tables (docs/prd-point-coin.md §7, §8). -// Only the wallet processor should call its write methods: it is the one place that -// keeps balances, ledger rows and lots in step. -// -// Unlike the gamification repositories, every method goes through DBFromContext so it -// joins the caller's transaction. Writes and locks refuse to run without one. -type WalletRepository interface { - // LockWallet locks the customer's wallet row for the rest of the transaction, - // creating the row first if the customer has none yet. - LockWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) - // LockWallets locks two wallets, always in customer_id order so that two transfers - // in opposite directions cannot deadlock. The results come back in argument order. - LockWallets(ctx context.Context, a, b uuid.UUID) (*entities.CustomerWallet, *entities.CustomerWallet, error) - // AddBalance moves one balance by delta and returns the new balance. A debit that - // would make it negative changes nothing and returns ErrWalletInsufficientBalance. - AddBalance(ctx context.Context, customerID uuid.UUID, currency string, delta int64) (int64, error) - GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) - - CreateTransaction(ctx context.Context, walletTx *entities.WalletTransaction) error - // GetTransactionByIdempotencyKey returns nil, nil when no row has the key. - GetTransactionByIdempotencyKey(ctx context.Context, key string) (*entities.WalletTransaction, error) - - CreateLot(ctx context.Context, lot *entities.WalletLot) error - // GetLotsByIDs returns the lots with the given ids, expired or not, in no - // particular order. Ids that match no lot are left out. - GetLotsByIDs(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) - // ListLotsBySourceTransaction returns the lots a credit created, oldest first. - ListLotsBySourceTransaction(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error) - // ListActiveLots returns the lots that still have balance and have not expired at - // asOf, in the order they are spent (K9): soonest expiry first, lots without an - // expiry last, oldest first within the same expiry. - ListActiveLots(ctx context.Context, customerID uuid.UUID, currency string, asOf time.Time) ([]entities.WalletLot, error) - // ConsumeLot takes amount from a lot's remaining amount. Taking more than remains - // changes nothing and returns ErrWalletLotInsufficient. - ConsumeLot(ctx context.Context, lotID uuid.UUID, amount int64) error - - CreateAllocations(ctx context.Context, allocations []entities.WalletLotAllocation) error - ListAllocationsByTransaction(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error) -} - -type walletRepository struct { - db *gorm.DB -} - -func NewWalletRepository(db *gorm.DB) WalletRepository { - return &walletRepository{db: db} -} - -// txDB returns the caller's transaction, or ErrWalletTxRequired if there is none. -func (r *walletRepository) txDB(ctx context.Context) (*gorm.DB, error) { - if tx, ok := ctx.Value(txKey).(*gorm.DB); ok && tx != nil { - return tx.WithContext(ctx), nil - } - return nil, ErrWalletTxRequired -} - -func (r *walletRepository) LockWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) { - db, err := r.txDB(ctx) - if err != nil { - return nil, err - } - - // The wallet takes its organization from the customer, so the two cannot disagree. - // ON CONFLICT covers two first operations racing to create the same wallet. - err = db.Exec(`INSERT INTO customer_wallets (customer_id, organization_id) - SELECT id, organization_id FROM customers WHERE id = ? - ON CONFLICT (customer_id) DO NOTHING`, customerID).Error - if err != nil { - return nil, fmt.Errorf("failed to create customer wallet: %w", err) - } - - var wallet entities.CustomerWallet - err = db.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("customer_id = ?", customerID). - First(&wallet).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrWalletNotFound - } - return nil, fmt.Errorf("failed to lock customer wallet: %w", err) - } - return &wallet, nil -} - -func (r *walletRepository) LockWallets(ctx context.Context, a, b uuid.UUID) (*entities.CustomerWallet, *entities.CustomerWallet, error) { - if a == b { - return nil, nil, errors.New("wallet: cannot lock the same wallet twice") - } - - ids := []uuid.UUID{a, b} - sort.Slice(ids, func(i, j int) bool { return ids[i].String() < ids[j].String() }) - - locked := make(map[uuid.UUID]*entities.CustomerWallet, 2) - for _, id := range ids { - wallet, err := r.LockWallet(ctx, id) - if err != nil { - return nil, nil, err - } - locked[id] = wallet - } - return locked[a], locked[b], nil -} - -func (r *walletRepository) AddBalance(ctx context.Context, customerID uuid.UUID, currency string, delta int64) (int64, error) { - db, err := r.txDB(ctx) - if err != nil { - return 0, err - } - - var column string - switch currency { - case constants.WalletCurrencyPoint: - column = "point_balance" - case constants.WalletCurrencyCoin: - column = "coin_balance" - default: - return 0, fmt.Errorf("wallet: unknown currency %q", currency) - } - - // The WHERE clause makes an overdraft match no row instead of tripping the CHECK, - // so the caller gets a clean error and the transaction stays usable. - var balances []int64 - err = db.Raw(`UPDATE customer_wallets - SET `+column+` = `+column+` + ?, updated_at = NOW() - WHERE customer_id = ? AND `+column+` + ? >= 0 - RETURNING `+column, delta, customerID, delta). - Scan(&balances).Error - if err != nil { - return 0, fmt.Errorf("failed to update wallet balance: %w", err) - } - if len(balances) == 0 { - if delta >= 0 { - return 0, ErrWalletNotFound - } - return 0, ErrWalletInsufficientBalance - } - return balances[0], nil -} - -func (r *walletRepository) GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) { - var wallet entities.CustomerWallet - err := DBFromContext(ctx, r.db).WithContext(ctx). - Where("customer_id = ?", customerID). - First(&wallet).Error - if err != nil { - return nil, err - } - return &wallet, nil -} - -func (r *walletRepository) CreateTransaction(ctx context.Context, walletTx *entities.WalletTransaction) error { - db, err := r.txDB(ctx) - if err != nil { - return err - } - return db.Create(walletTx).Error -} - -func (r *walletRepository) GetTransactionByIdempotencyKey(ctx context.Context, key string) (*entities.WalletTransaction, error) { - // Find rather than First: a new key is the normal case, and First would log every - // one of them as a "record not found" error. - var walletTxs []entities.WalletTransaction - err := DBFromContext(ctx, r.db).WithContext(ctx). - Where("idempotency_key = ?", key). - Limit(1). - Find(&walletTxs).Error - if err != nil { - return nil, fmt.Errorf("failed to get wallet transaction by idempotency key: %w", err) - } - if len(walletTxs) == 0 { - return nil, nil - } - return &walletTxs[0], nil -} - -func (r *walletRepository) CreateLot(ctx context.Context, lot *entities.WalletLot) error { - db, err := r.txDB(ctx) - if err != nil { - return err - } - return db.Create(lot).Error -} - -func (r *walletRepository) GetLotsByIDs(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) { - var lots []entities.WalletLot - if len(ids) == 0 { - return lots, nil - } - err := DBFromContext(ctx, r.db).WithContext(ctx). - Where("id IN ?", ids). - Find(&lots).Error - if err != nil { - return nil, fmt.Errorf("failed to get wallet lots: %w", err) - } - return lots, nil -} - -func (r *walletRepository) ListLotsBySourceTransaction(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error) { - var lots []entities.WalletLot - err := DBFromContext(ctx, r.db).WithContext(ctx). - Where("source_transaction_id = ?", transactionID). - Order("created_at, id"). - Find(&lots).Error - if err != nil { - return nil, fmt.Errorf("failed to list wallet lots by source transaction: %w", err) - } - return lots, nil -} - -func (r *walletRepository) ListActiveLots(ctx context.Context, customerID uuid.UUID, currency string, asOf time.Time) ([]entities.WalletLot, error) { - var lots []entities.WalletLot - // Filter and order match idx_wallet_lots_consume. - err := DBFromContext(ctx, r.db).WithContext(ctx). - Where("customer_id = ? AND currency = ? AND remaining_amount > 0", customerID, currency). - Where("(expires_at IS NULL OR expires_at > ?)", asOf). - Order("expires_at NULLS LAST, created_at, id"). - Find(&lots).Error - if err != nil { - return nil, fmt.Errorf("failed to list active wallet lots: %w", err) - } - return lots, nil -} - -func (r *walletRepository) ConsumeLot(ctx context.Context, lotID uuid.UUID, amount int64) error { - if amount <= 0 { - return fmt.Errorf("wallet: lot consumption must be positive, got %d", amount) - } - db, err := r.txDB(ctx) - if err != nil { - return err - } - - result := db.Exec(`UPDATE wallet_lots SET remaining_amount = remaining_amount - ? - WHERE id = ? AND remaining_amount >= ?`, amount, lotID, amount) - if result.Error != nil { - return fmt.Errorf("failed to consume wallet lot: %w", result.Error) - } - if result.RowsAffected == 0 { - return ErrWalletLotInsufficient - } - return nil -} - -func (r *walletRepository) CreateAllocations(ctx context.Context, allocations []entities.WalletLotAllocation) error { - if len(allocations) == 0 { - return nil - } - db, err := r.txDB(ctx) - if err != nil { - return err - } - return db.Create(&allocations).Error -} - -func (r *walletRepository) ListAllocationsByTransaction(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error) { - var allocations []entities.WalletLotAllocation - err := DBFromContext(ctx, r.db).WithContext(ctx). - Where("transaction_id = ?", transactionID). - Find(&allocations).Error - if err != nil { - return nil, fmt.Errorf("failed to list wallet lot allocations: %w", err) - } - return allocations, nil -} diff --git a/internal/repository/wallet_repository_test.go b/internal/repository/wallet_repository_test.go deleted file mode 100644 index bfbdd30..0000000 --- a/internal/repository/wallet_repository_test.go +++ /dev/null @@ -1,353 +0,0 @@ -package repository - -import ( - "context" - "errors" - "os" - "sync" - "testing" - "time" - - "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/entities" -) - -// These tests need a real Postgres, because what they check (row locks and -// conditional updates) only exists there. Point TEST_DATABASE_URL at a database with -// all migrations applied, e.g. -// -// TEST_DATABASE_URL=postgres://user:pass@localhost:5432/pos_test?sslmode=disable go test ./internal/repository/ -run Wallet -// -// Each test creates its own organization and customers and removes them afterwards. -func walletTestDB(t *testing.T) *gorm.DB { - t.Helper() - 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) - return db -} - -type walletFixture struct { - db *gorm.DB - repo WalletRepository - txm *TxManager - orgID uuid.UUID - customers []uuid.UUID -} - -func newWalletFixture(t *testing.T, customerCount int) *walletFixture { - t.Helper() - db := walletTestDB(t) - f := &walletFixture{db: db, repo: NewWalletRepository(db), txm: NewTxManager(db), orgID: uuid.New()} - - require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet test', 'basic')`, f.orgID).Error) - for i := 0; i < customerCount; i++ { - id := uuid.New() - require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'wallet test')`, id, f.orgID).Error) - f.customers = append(f.customers, id) - } - - t.Cleanup(func() { - for _, q := range []string{ - `DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, - `DELETE FROM wallet_lots WHERE customer_id IN ?`, - `DELETE FROM wallet_transactions WHERE customer_id IN ?`, - `DELETE FROM customer_wallets WHERE customer_id IN ?`, - `DELETE FROM customers WHERE id IN ?`, - } { - db.Exec(q, f.customers) - } - db.Exec(`DELETE FROM organizations WHERE id = ?`, f.orgID) - }) - return f -} - -// inTx runs fn in a transaction and fails the test on error. -func (f *walletFixture) inTx(t *testing.T, fn func(ctx context.Context) error) { - t.Helper() - require.NoError(t, f.txm.WithTransaction(context.Background(), fn)) -} - -// credit writes a ledger row and a lot and moves the balance, the minimum the -// database accepts for a credit. -func (f *walletFixture) credit(t *testing.T, ctx context.Context, customerID uuid.UUID, amount int64, expiresAt *time.Time) *entities.WalletLot { - t.Helper() - balance, err := f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyPoint, amount) - require.NoError(t, err) - walletTx := &entities.WalletTransaction{ - OrganizationID: f.orgID, - CustomerID: customerID, - Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypeMigration, - Amount: amount, - BalanceAfter: balance, - ReferenceType: constants.WalletRefTypeLegacyPoints, - ReferenceID: uuid.New(), - Description: "test", - } - require.NoError(t, f.repo.CreateTransaction(ctx, walletTx)) - lot := &entities.WalletLot{ - OrganizationID: f.orgID, - CustomerID: customerID, - Currency: constants.WalletCurrencyPoint, - SourceTransactionID: walletTx.ID, - OriginalAmount: amount, - RemainingAmount: amount, - ExpiresAt: expiresAt, - } - require.NoError(t, f.repo.CreateLot(ctx, lot)) - return lot -} - -func TestWalletRepository_WritesRequireTransaction(t *testing.T) { - f := newWalletFixture(t, 1) - ctx := context.Background() - - _, err := f.repo.LockWallet(ctx, f.customers[0]) - assert.ErrorIs(t, err, ErrWalletTxRequired) - _, err = f.repo.AddBalance(ctx, f.customers[0], constants.WalletCurrencyPoint, 10) - assert.ErrorIs(t, err, ErrWalletTxRequired) - assert.ErrorIs(t, f.repo.ConsumeLot(ctx, uuid.New(), 1), ErrWalletTxRequired) - assert.ErrorIs(t, f.repo.CreateTransaction(ctx, &entities.WalletTransaction{}), ErrWalletTxRequired) - assert.ErrorIs(t, f.repo.CreateLot(ctx, &entities.WalletLot{}), ErrWalletTxRequired) -} - -func TestWalletRepository_LockWalletCreatesWallet(t *testing.T) { - f := newWalletFixture(t, 1) - - f.inTx(t, func(ctx context.Context) error { - wallet, err := f.repo.LockWallet(ctx, f.customers[0]) - require.NoError(t, err) - assert.Equal(t, f.orgID, wallet.OrganizationID, "organization comes from the customer") - assert.Zero(t, wallet.PointBalance) - assert.Zero(t, wallet.CoinBalance) - - // Locking again in the same transaction finds the same row. - again, err := f.repo.LockWallet(ctx, f.customers[0]) - require.NoError(t, err) - assert.Equal(t, wallet.CustomerID, again.CustomerID) - return nil - }) - - f.inTx(t, func(ctx context.Context) error { - _, err := f.repo.LockWallet(ctx, uuid.New()) - assert.ErrorIs(t, err, ErrWalletNotFound) - return nil - }) -} - -func TestWalletRepository_AddBalanceRejectsOverdraft(t *testing.T) { - f := newWalletFixture(t, 1) - customerID := f.customers[0] - - f.inTx(t, func(ctx context.Context) error { - _, err := f.repo.LockWallet(ctx, customerID) - require.NoError(t, err) - - balance, err := f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyPoint, 5) - require.NoError(t, err) - assert.Equal(t, int64(5), balance) - - _, err = f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyPoint, -6) - assert.ErrorIs(t, err, ErrWalletInsufficientBalance) - - // Coin is a separate balance: point balance does not cover it. - _, err = f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyCoin, -1) - assert.ErrorIs(t, err, ErrWalletInsufficientBalance) - - // The failed update left the transaction usable and the balance untouched. - balance, err = f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyPoint, -5) - require.NoError(t, err) - assert.Equal(t, int64(0), balance) - return nil - }) - - wallet, err := f.repo.GetWallet(context.Background(), customerID) - require.NoError(t, err) - assert.Equal(t, int64(0), wallet.PointBalance) - assert.Equal(t, int64(0), wallet.CoinBalance) -} - -func TestWalletRepository_AddBalanceWithoutWallet(t *testing.T) { - f := newWalletFixture(t, 1) - - f.inTx(t, func(ctx context.Context) error { - _, err := f.repo.AddBalance(ctx, f.customers[0], constants.WalletCurrencyPoint, 5) - assert.ErrorIs(t, err, ErrWalletNotFound) - _, err = f.repo.AddBalance(ctx, f.customers[0], "GOLD", 5) - assert.Error(t, err) - return nil - }) -} - -func TestWalletRepository_ConsumeLotRejectsOverdraw(t *testing.T) { - f := newWalletFixture(t, 1) - customerID := f.customers[0] - - f.inTx(t, func(ctx context.Context) error { - _, err := f.repo.LockWallet(ctx, customerID) - require.NoError(t, err) - lot := f.credit(t, ctx, customerID, 10, nil) - - require.NoError(t, f.repo.ConsumeLot(ctx, lot.ID, 4)) - assert.ErrorIs(t, f.repo.ConsumeLot(ctx, lot.ID, 7), ErrWalletLotInsufficient) - require.NoError(t, f.repo.ConsumeLot(ctx, lot.ID, 6)) - assert.ErrorIs(t, f.repo.ConsumeLot(ctx, lot.ID, 1), ErrWalletLotInsufficient) - assert.Error(t, f.repo.ConsumeLot(ctx, lot.ID, 0)) - return nil - }) -} - -// Two goroutines lock the same wallet and do a read-modify-write with a pause in -// between. Without the lock both would read 0 and the result would be 1. -func TestWalletRepository_LockWalletSerializes(t *testing.T) { - f := newWalletFixture(t, 1) - customerID := f.customers[0] - - // Create the wallet up front. Otherwise the second goroutine's INSERT ... ON - // CONFLICT waits on the first one's uncommitted insert, which serializes them - // even without FOR UPDATE and the test would prove nothing about the lock. - f.inTx(t, func(ctx context.Context) error { - _, err := f.repo.LockWallet(ctx, customerID) - return err - }) - - type window struct{ locked, released time.Time } - windows := make([]window, 2) - var wg sync.WaitGroup - errs := make(chan error, 2) - - for i := 0; i < 2; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - errs <- f.txm.WithTransaction(context.Background(), func(ctx context.Context) error { - wallet, err := f.repo.LockWallet(ctx, customerID) - if err != nil { - return err - } - windows[i].locked = time.Now() - time.Sleep(300 * time.Millisecond) - db := DBFromContext(ctx, f.db) - if err := db.Exec(`UPDATE customer_wallets SET point_balance = ? WHERE customer_id = ?`, - wallet.PointBalance+1, customerID).Error; err != nil { - return err - } - windows[i].released = time.Now() - return nil - }) - }(i) - } - wg.Wait() - close(errs) - for err := range errs { - require.NoError(t, err) - } - - wallet, err := f.repo.GetWallet(context.Background(), customerID) - require.NoError(t, err) - assert.Equal(t, int64(2), wallet.PointBalance, "second transaction must see the first one's write") - - first, second := windows[0], windows[1] - if second.locked.Before(first.locked) { - first, second = second, first - } - assert.False(t, second.locked.Before(first.released), "second lock was taken while the first was held") -} - -// Transfers in opposite directions lock the same pair of wallets. Because LockWallets -// always locks in customer_id order, they queue instead of deadlocking. -func TestWalletRepository_LockWalletsOppositeOrderDoesNotDeadlock(t *testing.T) { - f := newWalletFixture(t, 2) - a, b := f.customers[0], f.customers[1] - // Existing wallets, for the same reason as in LockWalletSerializes. - f.inTx(t, func(ctx context.Context) error { - _, _, err := f.repo.LockWallets(ctx, a, b) - return err - }) - - var wg sync.WaitGroup - errs := make(chan error, 20) - for i := 0; i < 10; i++ { - for _, pair := range [][2]uuid.UUID{{a, b}, {b, a}} { - wg.Add(1) - go func(first, second uuid.UUID) { - defer wg.Done() - errs <- f.txm.WithTransaction(context.Background(), func(ctx context.Context) error { - w1, w2, err := f.repo.LockWallets(ctx, first, second) - if err != nil { - return err - } - if w1.CustomerID != first || w2.CustomerID != second { - return errors.New("wallets returned out of argument order") - } - time.Sleep(20 * time.Millisecond) - return nil - }) - }(pair[0], pair[1]) - } - } - wg.Wait() - close(errs) - for err := range errs { - require.NoError(t, err) - } - - f.inTx(t, func(ctx context.Context) error { - _, _, err := f.repo.LockWallets(ctx, a, a) - assert.Error(t, err) - return nil - }) -} - -func TestWalletRepository_ListActiveLotsOrder(t *testing.T) { - f := newWalletFixture(t, 1) - customerID := f.customers[0] - now := time.Now() - at := func(d time.Duration) *time.Time { v := now.Add(d); return &v } - - create := func(expiresAt *time.Time) *entities.WalletLot { - var lot *entities.WalletLot - f.inTx(t, func(ctx context.Context) error { - _, err := f.repo.LockWallet(ctx, customerID) - require.NoError(t, err) - lot = f.credit(t, ctx, customerID, 10, expiresAt) - return nil - }) - return lot - } - neverOld := create(nil) - late := create(at(48 * time.Hour)) - soon := create(at(time.Hour)) - neverNew := create(nil) - expired := create(at(-time.Hour)) - empty := create(at(30 * time.Minute)) - f.inTx(t, func(ctx context.Context) error { - return f.repo.ConsumeLot(ctx, empty.ID, 10) - }) - - lots, err := f.repo.ListActiveLots(context.Background(), customerID, constants.WalletCurrencyPoint, now) - require.NoError(t, err) - - var got []uuid.UUID - for _, lot := range lots { - got = append(got, lot.ID) - } - assert.Equal(t, []uuid.UUID{soon.ID, late.ID, neverOld.ID, neverNew.ID}, got, - "soonest expiry first, no expiry last and oldest first, expired and empty lots left out") - assert.NotContains(t, got, expired.ID) - - coinLots, err := f.repo.ListActiveLots(context.Background(), customerID, constants.WalletCurrencyCoin, now) - require.NoError(t, err) - assert.Empty(t, coinLots) -} diff --git a/internal/repository/wallet_trace_repository.go b/internal/repository/wallet_trace_repository.go deleted file mode 100644 index aad7677..0000000 --- a/internal/repository/wallet_trace_repository.go +++ /dev/null @@ -1,109 +0,0 @@ -package repository - -import ( - "context" - "errors" - "fmt" - - "github.com/google/uuid" - "gorm.io/gorm" - - "apskel-pos-be/internal/entities" -) - -// ErrWalletTransactionNotFound means the ledger row does not exist or belongs to -// another organization. -var ErrWalletTransactionNotFound = errors.New("wallet: transaction not found") - -// WalletTraceRepository reads what tracing a ledger row lot by lot needs -// (docs/prd-point-coin.md F7, §8.1). Nothing here changes a balance. -type WalletTraceRepository interface { - // GetTransaction returns ErrWalletTransactionNotFound unless the row belongs to - // the organization. - GetTransaction(ctx context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error) - GetTransactions(ctx context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error) - // ListAllocations returns what a debit took from each lot, in the order it took. - ListAllocations(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error) - // ListLotsBySource returns the lots a credit created, oldest first. - ListLotsBySource(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error) - GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) - CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) -} - -type walletTraceRepository struct { - db *gorm.DB - names *walletAdminRepository -} - -func NewWalletTraceRepository(db *gorm.DB) WalletTraceRepository { - return &walletTraceRepository{db: db, names: &walletAdminRepository{db: db}} -} - -func (r *walletTraceRepository) GetTransaction(ctx context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error) { - var rows []entities.WalletTransaction - err := DBFromContext(ctx, r.db).WithContext(ctx). - Where("id = ? AND organization_id = ?", id, organizationID). - Limit(1). - Find(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to get wallet transaction: %w", err) - } - if len(rows) == 0 { - return nil, ErrWalletTransactionNotFound - } - return &rows[0], nil -} - -func (r *walletTraceRepository) GetTransactions(ctx context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error) { - var rows []entities.WalletTransaction - if len(ids) == 0 { - return rows, nil - } - if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id IN ?", ids).Find(&rows).Error; err != nil { - return nil, fmt.Errorf("failed to get wallet transactions: %w", err) - } - return rows, nil -} - -func (r *walletTraceRepository) ListAllocations(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error) { - var rows []entities.WalletLotAllocation - // A debit draws in K9 order, so the lots' order is the order it took from them. - err := DBFromContext(ctx, r.db).WithContext(ctx). - Table("wallet_lot_allocations a"). - Select("a.*"). - Joins("JOIN wallet_lots l ON l.id = a.lot_id"). - Where("a.transaction_id = ?", transactionID). - Order("l.expires_at NULLS LAST, l.created_at, l.id"). - Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to list wallet lot allocations: %w", err) - } - return rows, nil -} - -func (r *walletTraceRepository) ListLotsBySource(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error) { - var lots []entities.WalletLot - err := DBFromContext(ctx, r.db).WithContext(ctx). - Where("source_transaction_id = ?", transactionID). - Order("created_at, id"). - Find(&lots).Error - if err != nil { - return nil, fmt.Errorf("failed to list wallet lots: %w", err) - } - return lots, nil -} - -func (r *walletTraceRepository) GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) { - var lots []entities.WalletLot - if len(ids) == 0 { - return lots, nil - } - if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id IN ?", ids).Find(&lots).Error; err != nil { - return nil, fmt.Errorf("failed to get wallet lots: %w", err) - } - return lots, nil -} - -func (r *walletTraceRepository) CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) { - return r.names.CustomerNames(ctx, ids) -} diff --git a/internal/router/router.go b/internal/router/router.go index a06e26e..6da7d3f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -54,19 +54,12 @@ type Router struct { productOutletPriceHandler *handler.ProductOutletPriceHandler expenseHandler *handler.ExpenseHandler cashAdvanceHandler *handler.CashAdvanceHandler - walletAdminHandler *handler.WalletAdminHandler - loyaltySettingsHandler *handler.LoyaltySettingsHandler - customerPinHandler *handler.CustomerPinHandler - pointPaymentHandler *handler.PointPaymentHandler - customerOrderPaymentHandler *handler.CustomerOrderPaymentHandler - customerWalletHandler *handler.CustomerWalletHandler - customerDeviceHandler *handler.CustomerDeviceHandler - authMiddleware *middleware.AuthMiddleware + authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -112,13 +105,6 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator), expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator), cashAdvanceHandler: handler.NewCashAdvanceHandler(cashAdvanceService, cashAdvanceValidator), - walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator), - loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService), - customerPinHandler: handler.NewCustomerPinHandler(customerPinService), - pointPaymentHandler: handler.NewPointPaymentHandler(pointPaymentService), - customerOrderPaymentHandler: handler.NewCustomerOrderPaymentHandler(customerOrderPaymentService), - customerWalletHandler: handler.NewCustomerWalletHandler(customerWalletService), - customerDeviceHandler: handler.NewCustomerDeviceHandler(customerDeviceService), redisClient: redisClient, } } @@ -171,22 +157,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.GET("/points", r.customerPointsHandler.GetCustomerPoints) customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens) customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) - customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions) - customer.GET("/wallet/expiring", r.customerPointsHandler.GetCustomerWalletExpiring) - customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode) - customer.GET("/wallet/exchange/preview", r.customerWalletHandler.PreviewExchange) - customer.POST("/wallet/exchange", r.customerWalletHandler.Exchange) - customer.GET("/wallet/transfer/recipient", r.customerWalletHandler.TransferRecipient) - customer.POST("/wallet/transfer", r.customerWalletHandler.Transfer) - customer.PUT("/devices", r.customerDeviceHandler.Register) - customer.DELETE("/devices/:device_id", r.customerDeviceHandler.Unregister) - customer.POST("/orders/:id/pay-with-points", r.customerOrderPaymentHandler.PayWithPoints) - // PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11) - customer.GET("/pin/status", r.customerPinHandler.Status) - customer.POST("/pin/otp", r.customerPinHandler.RequestOtp) - customer.POST("/pin", r.customerPinHandler.CreatePin) - customer.PUT("/pin", r.customerPinHandler.ChangePin) - customer.POST("/pin/reset", r.customerPinHandler.ResetPin) customer.GET("/games", r.customerPointsHandler.GetCustomerGames) customer.GET("/ferris-wheel", r.customerPointsHandler.GetFerrisWheelGame) customer.POST("/spin", r.spinGameHandler.PlaySpinGame) @@ -310,7 +280,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { { orders.GET("", r.orderHandler.ListOrders) orders.GET("/:id", r.orderHandler.GetOrderByID) - orders.GET("/:id/point-payment/preview", r.pointPaymentHandler.Preview) orders.POST("", r.orderHandler.CreateOrder) orders.POST("/:id/add-items", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.AddToOrder) orders.PUT("/:id", r.orderHandler.UpdateOrder) @@ -542,6 +511,31 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { gamification := protected.Group("/marketing") gamification.Use(r.authMiddleware.RequireAdminOrManager()) { + //customerPoints := gamification.Group("/customer-points") + //{ + // customerPoints.POST("", r.gamificationHandler.CreateCustomerPoints) + // customerPoints.GET("", r.gamificationHandler.ListCustomerPoints) + // customerPoints.GET("/:id", r.gamificationHandler.GetCustomerPoints) + // customerPoints.PUT("/:id", r.gamificationHandler.UpdateCustomerPoints) + // customerPoints.DELETE("/:id", r.gamificationHandler.DeleteCustomerPoints) + // customerPoints.GET("/customer/:customer_id", r.gamificationHandler.GetCustomerPointsByCustomerID) + // customerPoints.POST("/customer/:customer_id/add", r.gamificationHandler.AddCustomerPoints) + // customerPoints.POST("/customer/:customer_id/deduct", r.gamificationHandler.DeductCustomerPoints) + //} + + // Customer Tokens + //customerTokens := gamification.Group("/customer-tokens") + //{ + // customerTokens.POST("", r.gamificationHandler.CreateCustomerTokens) + // customerTokens.GET("", r.gamificationHandler.ListCustomerTokens) + // customerTokens.GET("/:id", r.gamificationHandler.GetCustomerTokens) + // customerTokens.PUT("/:id", r.gamificationHandler.UpdateCustomerTokens) + // customerTokens.DELETE("/:id", r.gamificationHandler.DeleteCustomerTokens) + // customerTokens.GET("/customer/:customer_id/type/:token_type", r.gamificationHandler.GetCustomerTokensByCustomerIDAndType) + // customerTokens.POST("/customer/:customer_id/type/:token_type/add", r.gamificationHandler.AddCustomerTokens) + // customerTokens.POST("/customer/:customer_id/type/:token_type/deduct", r.gamificationHandler.DeductCustomerTokens) + //} + // Tiers tiers := gamification.Group("/tiers") { @@ -620,22 +614,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { } // Campaign Rules - // EnakPoint & EnakCoin settings of the organization (docs/prd-point-coin.md F2) - gamification.GET("/loyalty-settings", r.loyaltySettingsHandler.GetOrganizationSettings) - gamification.PUT("/loyalty-settings", r.authMiddleware.RequireLoyaltyManager(), r.loyaltySettingsHandler.UpdateOrganizationSettings) - gamification.GET("/loyalty-settings/history", r.loyaltySettingsHandler.ListHistory) - - // EnakPoint & EnakCoin wallet of one customer (docs/prd-point-coin.md F7) - marketingCustomers := gamification.Group("/customers") - { - marketingCustomers.GET("/:id/wallet", r.walletAdminHandler.GetCustomerWallet) - marketingCustomers.POST("/:id/wallet/adjust", r.authMiddleware.RequireLoyaltyManager(), r.walletAdminHandler.AdjustCustomerWallet) - marketingCustomers.DELETE("/:id/pin", r.authMiddleware.RequireLoyaltyManager(), r.customerPinHandler.RemovePin) - marketingCustomers.GET("/:id/security-events", r.customerPinHandler.ListSecurityEvents) - } - // Trace one ledger row lot by lot back to where its balance came from (F7, §8.1) - gamification.GET("/wallet-transactions/:id/trace", r.walletAdminHandler.TraceTransaction) - campaignRules := gamification.Group("/campaign-rules") { campaignRules.POST("", r.campaignHandler.CreateCampaignRule) @@ -656,9 +634,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { outlets.PUT("/detail/:id", r.outletHandler.UpdateOutlet) outlets.GET("/printer-setting/:outlet_id", r.outletSettingHandler.GetPrinterSettings) outlets.PUT("/printer-setting/:outlet_id", r.outletSettingHandler.UpdatePrinterSettings) - // EnakPoint & EnakCoin settings of the outlet (docs/prd-point-coin.md F1) - outlets.GET("/:outlet_id/loyalty-settings", r.loyaltySettingsHandler.GetOutletSettings) - outlets.PUT("/:outlet_id/loyalty-settings", r.authMiddleware.RequireLoyaltyManager(), r.loyaltySettingsHandler.UpdateOutletSettings) outlets.GET("/:outlet_id/tables/available", r.tableHandler.GetAvailableTables) outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables) // Reports diff --git a/internal/router/router_test.go b/internal/router/router_test.go deleted file mode 100644 index 40db753..0000000 --- a/internal/router/router_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package router - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "apskel-pos-be/config" - "apskel-pos-be/internal/middleware" -) - -// Gin panics at startup when two routes disagree on a path parameter's name at the -// same position (say /outlets/:id/x next to /outlets/:outlet_id/y). Registering every -// route here catches that in a test instead of on deploy. Handlers are nil: nothing is -// served, only registered. -func TestAllRoutesRegister(t *testing.T) { - r := &Router{ - config: &config.Config{}, - authMiddleware: middleware.NewAuthMiddleware(nil), - customerAuthMiddleware: middleware.NewCustomerAuthMiddleware("test"), - } - engine := r.Init() - - registered := map[string]bool{} - for _, route := range engine.Routes() { - registered[route.Method+" "+route.Path] = true - } - for _, want := range []string{ - "GET /api/v1/customer/wallet", - "GET /api/v1/customer/wallet/transactions", - "GET /api/v1/customer/wallet/expiring", - "GET /api/v1/marketing/customers/:id/wallet", - "POST /api/v1/marketing/customers/:id/wallet/adjust", - "GET /api/v1/marketing/wallet-transactions/:id/trace", - "GET /api/v1/outlets/:outlet_id/loyalty-settings", - "PUT /api/v1/outlets/:outlet_id/loyalty-settings", - "GET /api/v1/marketing/loyalty-settings", - "PUT /api/v1/marketing/loyalty-settings", - "GET /api/v1/marketing/loyalty-settings/history", - "POST /api/v1/customer/wallet/payment-code", - "GET /api/v1/customer/wallet/exchange/preview", - "POST /api/v1/customer/wallet/exchange", - "GET /api/v1/customer/wallet/transfer/recipient", - "POST /api/v1/customer/wallet/transfer", - "PUT /api/v1/customer/devices", - "DELETE /api/v1/customer/devices/:device_id", - "GET /api/v1/orders/:id/point-payment/preview", - "POST /api/v1/customer/orders/:id/pay-with-points", - "GET /api/v1/customer/pin/status", - "POST /api/v1/customer/pin/otp", - "POST /api/v1/customer/pin", - "PUT /api/v1/customer/pin", - "POST /api/v1/customer/pin/reset", - "DELETE /api/v1/marketing/customers/:id/pin", - "GET /api/v1/marketing/customers/:id/security-events", - } { - assert.True(t, registered[want], want) - } -} diff --git a/internal/service/campaign_service.go b/internal/service/campaign_service.go index bc42ba2..95a671a 100644 --- a/internal/service/campaign_service.go +++ b/internal/service/campaign_service.go @@ -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 == "COINS" || req.RewardType == "TOKENS" { + if req.RewardType == "POINTS" || 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 == "COINS" || req.RewardType == "TOKENS" { + if req.RewardType == "POINTS" || 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", "COINS", "TOKENS", "MIXED"} + validTypes := []string{"REWARD", "POINTS", "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) + return fmt.Errorf("invalid campaign type: %s. Valid types are: REWARD, POINTS, TOKENS, 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 == "COINS" || rule.RewardType == "TOKENS" { + if rule.RewardType == "POINTS" || 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", "COINS", "TOKENS", "REWARD"} + validTypes := []string{"POINTS", "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) + return fmt.Errorf("invalid reward type: %s. Valid types are: POINTS, TOKENS, REWARD", rewardType) } diff --git a/internal/service/customer_device_service.go b/internal/service/customer_device_service.go deleted file mode 100644 index 157d93b..0000000 --- a/internal/service/customer_device_service.go +++ /dev/null @@ -1,58 +0,0 @@ -package service - -import ( - "context" - "errors" - - "github.com/google/uuid" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" -) - -// CustomerDeviceService registers the customer app's devices for push notifications. -type CustomerDeviceService interface { - Register(ctx context.Context, customerID uuid.UUID, req *contract.RegisterCustomerDeviceRequest) *contract.Response - Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) *contract.Response -} - -type CustomerDeviceServiceImpl struct { - devices *processor.CustomerDeviceProcessor -} - -func NewCustomerDeviceService(devices *processor.CustomerDeviceProcessor) *CustomerDeviceServiceImpl { - return &CustomerDeviceServiceImpl{devices: devices} -} - -func (s *CustomerDeviceServiceImpl) Register(ctx context.Context, customerID uuid.UUID, req *contract.RegisterCustomerDeviceRequest) *contract.Response { - err := s.devices.Register(ctx, repository.CustomerDevice{ - CustomerID: customerID, - DeviceID: req.DeviceID, - FCMToken: req.FCMToken, - Platform: req.Platform, - AppVersion: req.AppVersion, - }) - if err != nil { - return customerDeviceErrorResponse(err) - } - return contract.BuildSuccessResponse(map[string]interface{}{"device_id": req.DeviceID}) -} - -func (s *CustomerDeviceServiceImpl) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) *contract.Response { - if err := s.devices.Unregister(ctx, customerID, deviceID); err != nil { - return customerDeviceErrorResponse(err) - } - return contract.BuildSuccessResponse(map[string]interface{}{"device_id": deviceID}) -} - -func customerDeviceErrorResponse(err error) *contract.Response { - code := constants.InternalServerErrorCode - if errors.Is(err, processor.ErrInvalidCustomerDevice) { - code = constants.ValidationErrorCode - } - return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(code, constants.RequestEntity, err.Error()), - }) -} diff --git a/internal/service/customer_order_payment_service.go b/internal/service/customer_order_payment_service.go deleted file mode 100644 index b7e57c4..0000000 --- a/internal/service/customer_order_payment_service.go +++ /dev/null @@ -1,34 +0,0 @@ -package service - -import ( - "context" - - "github.com/google/uuid" - - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/transformer" -) - -// CustomerOrderPaymentService lets customers pay their own orders with EnakPoint in the -// app or a self-order (docs/prd-point-coin.md F9). -type CustomerOrderPaymentService interface { - PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response -} - -type CustomerOrderPaymentServiceImpl struct { - orders processor.OrderProcessor -} - -func NewCustomerOrderPaymentService(orders processor.OrderProcessor) *CustomerOrderPaymentServiceImpl { - return &CustomerOrderPaymentServiceImpl{orders: orders} -} - -func (s *CustomerOrderPaymentServiceImpl) PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response { - payment, err := s.orders.PayWithPointsInApp(ctx, customerID, orderID, req.Points, req.Pin, info) - if err != nil { - return PointPaymentErrorResponse(err) - } - return contract.BuildSuccessResponse(transformer.PaymentModelToContract(payment)) -} diff --git a/internal/service/customer_pin_service.go b/internal/service/customer_pin_service.go deleted file mode 100644 index 9fdc5b4..0000000 --- a/internal/service/customer_pin_service.go +++ /dev/null @@ -1,140 +0,0 @@ -package service - -import ( - "context" - "errors" - - "github.com/google/uuid" - - "apskel-pos-be/internal/appcontext" - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" -) - -// CustomerPinService serves the customer's PIN (docs/prd-point-coin.md F11) and the -// dashboard's view of it. -type CustomerPinService interface { - Status(ctx context.Context, customerID uuid.UUID) *contract.Response - RequestOtp(ctx context.Context, customerID uuid.UUID, req *contract.RequestPinOtpRequest) *contract.Response - CreatePin(ctx context.Context, customerID uuid.UUID, req *contract.CreateCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response - ChangePin(ctx context.Context, customerID uuid.UUID, req *contract.ChangeCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response - ResetPin(ctx context.Context, customerID uuid.UUID, req *contract.ResetCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response - - RemovePin(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.RemoveCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response - ListSecurityEvents(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, page, limit int) *contract.Response - - // IssuePaymentCode checks the PIN and returns a one-time code for the cashier (F9). - IssuePaymentCode(ctx context.Context, customerID uuid.UUID, req *contract.IssuePaymentCodeRequest, info models.CustomerPinRequestInfo) *contract.Response -} - -type CustomerPinServiceImpl struct { - pins *processor.CustomerPinProcessor - codes *processor.PaymentCodeProcessor -} - -func NewCustomerPinService(pins *processor.CustomerPinProcessor, codes *processor.PaymentCodeProcessor) *CustomerPinServiceImpl { - return &CustomerPinServiceImpl{pins: pins, codes: codes} -} - -func (s *CustomerPinServiceImpl) Status(ctx context.Context, customerID uuid.UUID) *contract.Response { - status, err := s.pins.Status(ctx, customerID) - if err != nil { - return PinErrorResponse(err) - } - return contract.BuildSuccessResponse(status) -} - -func (s *CustomerPinServiceImpl) RequestOtp(ctx context.Context, customerID uuid.UUID, req *contract.RequestPinOtpRequest) *contract.Response { - otp, err := s.pins.RequestOtp(ctx, customerID, req.Purpose) - if err != nil { - return PinErrorResponse(err) - } - return contract.BuildSuccessResponse(otp) -} - -func (s *CustomerPinServiceImpl) CreatePin(ctx context.Context, customerID uuid.UUID, req *contract.CreateCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response { - if err := s.pins.CreatePin(ctx, customerID, req.OtpToken, req.OtpCode, req.Pin, req.ConfirmPin, info); err != nil { - return PinErrorResponse(err) - } - return s.Status(ctx, customerID) -} - -func (s *CustomerPinServiceImpl) ChangePin(ctx context.Context, customerID uuid.UUID, req *contract.ChangeCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response { - if err := s.pins.ChangePin(ctx, customerID, req.OldPin, req.Pin, req.ConfirmPin, info); err != nil { - return PinErrorResponse(err) - } - return s.Status(ctx, customerID) -} - -func (s *CustomerPinServiceImpl) ResetPin(ctx context.Context, customerID uuid.UUID, req *contract.ResetCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response { - if err := s.pins.ResetPin(ctx, customerID, req.OtpToken, req.OtpCode, req.Pin, req.ConfirmPin, info); err != nil { - return PinErrorResponse(err) - } - return s.Status(ctx, customerID) -} - -func (s *CustomerPinServiceImpl) RemovePin(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.RemoveCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response { - if err := s.pins.RemovePinByAdmin(ctx, apctx.OrganizationID, customerID, apctx.UserID, req.Reason, info); err != nil { - return PinErrorResponse(err) - } - return contract.BuildSuccessResponse(map[string]interface{}{"message": "PIN removed; the customer has to create a new one"}) -} - -func (s *CustomerPinServiceImpl) ListSecurityEvents(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, page, limit int) *contract.Response { - events, err := s.pins.ListEvents(ctx, apctx.OrganizationID, customerID, page, limit) - if err != nil { - return PinErrorResponse(err) - } - return contract.BuildSuccessResponse(events) -} - -// PinErrorResponse turns an error from a PIN-guarded action into a response the apps -// can act on. A *processor.PinError keeps its code (PIN_NOT_SET, PIN_INVALID, -// PIN_LOCKED, TRANSFER_BLOCKED) and puts the attempts left or the time it lifts in the -// response data. Other errors map to a validation or server error. -func PinErrorResponse(err error) *contract.Response { - var pinErr *processor.PinError - if errors.As(err, &pinErr) { - data := map[string]interface{}{"code": pinErr.Code} - switch pinErr.Code { - case processor.PinErrInvalid: - data["remaining_attempts"] = pinErr.RemainingAttempts - case processor.PinErrLocked: - data["locked_until"] = pinErr.Until - case processor.PinErrTransferBlocked: - data["transfer_blocked_until"] = pinErr.Until - } - return &contract.Response{ - Success: false, - Data: data, - Errors: []*contract.ResponseError{contract.NewResponseError(pinErr.Code, constants.CustomerPinServiceEntity, pinErr.Error())}, - } - } - - code := constants.InternalServerErrorCode - switch { - case errors.Is(err, repository.ErrPinCustomerNotFound): - code = constants.NotFoundErrorCode - case errors.Is(err, processor.ErrPinOtpTooSoon): - code = constants.TooManyRequestsErrorCode - case errors.Is(err, processor.ErrInvalidPinInput), - errors.Is(err, processor.ErrPinAlreadySet), - errors.Is(err, processor.ErrPinOtpInvalid), - errors.Is(err, processor.ErrPinNoPhone): - code = constants.ValidationErrorCode - } - return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(code, constants.CustomerPinServiceEntity, err.Error()), - }) -} - -func (s *CustomerPinServiceImpl) IssuePaymentCode(ctx context.Context, customerID uuid.UUID, req *contract.IssuePaymentCodeRequest, info models.CustomerPinRequestInfo) *contract.Response { - code, err := s.codes.Issue(ctx, customerID, req.Pin, info) - if err != nil { - return PinErrorResponse(err) - } - return contract.BuildSuccessResponse(code) -} diff --git a/internal/service/customer_pin_service_test.go b/internal/service/customer_pin_service_test.go deleted file mode 100644 index d59a69f..0000000 --- a/internal/service/customer_pin_service_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package service - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" - "apskel-pos-be/internal/util" -) - -func TestPinErrorResponse(t *testing.T) { - until := time.Date(2026, 9, 30, 12, 30, 0, 0, time.UTC) - for name, c := range map[string]struct { - err error - code string - status int - data map[string]interface{} - }{ - "not set": {&processor.PinError{Code: processor.PinErrNotSet}, "PIN_NOT_SET", http.StatusForbidden, map[string]interface{}{"code": "PIN_NOT_SET"}}, - "invalid": {fmt.Errorf("pay: %w", &processor.PinError{Code: processor.PinErrInvalid, RemainingAttempts: 2}), "PIN_INVALID", http.StatusBadRequest, map[string]interface{}{"code": "PIN_INVALID", "remaining_attempts": 2}}, - "locked": {&processor.PinError{Code: processor.PinErrLocked, Until: &until}, "PIN_LOCKED", http.StatusLocked, map[string]interface{}{"code": "PIN_LOCKED", "locked_until": &until}}, - "transfer": {&processor.PinError{Code: processor.PinErrTransferBlocked, Until: &until}, "TRANSFER_BLOCKED", http.StatusForbidden, map[string]interface{}{"code": "TRANSFER_BLOCKED", "transfer_blocked_until": &until}}, - } { - resp := PinErrorResponse(c.err) - assert.False(t, resp.Success, name) - assert.Equal(t, c.code, resp.Errors[0].Code, name) - assert.Equal(t, c.status, util.MapErrorCodeToHttpStatus(resp.Errors[0].Code), name) - assert.Equal(t, c.data, resp.Data, name) - } - - for name, c := range map[string]struct { - err error - status int - }{ - "weak PIN": {fmt.Errorf("%w: too easy", processor.ErrInvalidPinInput), http.StatusBadRequest}, - "bad OTP": {processor.ErrPinOtpInvalid, http.StatusBadRequest}, - "already set": {processor.ErrPinAlreadySet, http.StatusBadRequest}, - "too soon": {processor.ErrPinOtpTooSoon, http.StatusTooManyRequests}, - "no customer": {repository.ErrPinCustomerNotFound, http.StatusNotFound}, - "anything else": {fmt.Errorf("db down"), http.StatusInternalServerError}, - } { - resp := PinErrorResponse(c.err) - assert.Equal(t, c.status, util.MapErrorCodeToHttpStatus(resp.Errors[0].Code), name) - } -} diff --git a/internal/service/customer_points_service.go b/internal/service/customer_points_service.go index 6e85854..20b473d 100644 --- a/internal/service/customer_points_service.go +++ b/internal/service/customer_points_service.go @@ -12,8 +12,6 @@ type CustomerPointsService interface { GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) - GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) - GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) GetCustomerGames(ctx context.Context) (*models.GetCustomerGamesResponse, error) GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) } @@ -84,17 +82,3 @@ func (s *customerPointsService) GetFerrisWheelGame(ctx context.Context) (*models return response, nil } - -func (s *customerPointsService) GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) { - if customerID == "" { - return nil, fmt.Errorf("customer ID is required") - } - return s.customerPointsProcessor.GetCustomerWalletTransactionsAPI(ctx, customerID, query) -} - -func (s *customerPointsService) GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) { - if customerID == "" { - return nil, fmt.Errorf("customer ID is required") - } - return s.customerPointsProcessor.GetCustomerWalletExpiringAPI(ctx, customerID) -} diff --git a/internal/service/customer_wallet_service.go b/internal/service/customer_wallet_service.go deleted file mode 100644 index 897a32e..0000000 --- a/internal/service/customer_wallet_service.go +++ /dev/null @@ -1,91 +0,0 @@ -package service - -import ( - "context" - "errors" - - "github.com/google/uuid" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" -) - -// CustomerWalletService moves balance on the customer's own request: exchanging -// EnakCoin into EnakPoint and sending either to another customer -// (docs/prd-point-coin.md F4, F5). -type CustomerWalletService interface { - PreviewExchange(ctx context.Context, customerID uuid.UUID, coins int64) *contract.Response - Exchange(ctx context.Context, customerID uuid.UUID, req *contract.ExchangeCoinsRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response - TransferRecipient(ctx context.Context, customerID uuid.UUID, phoneNumber string) *contract.Response - Transfer(ctx context.Context, customerID uuid.UUID, req *contract.TransferWalletRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response -} - -type CustomerWalletServiceImpl struct { - exchanges *processor.WalletExchangeProcessor - transfers *processor.WalletTransferProcessor -} - -func NewCustomerWalletService(exchanges *processor.WalletExchangeProcessor, transfers *processor.WalletTransferProcessor) *CustomerWalletServiceImpl { - return &CustomerWalletServiceImpl{exchanges: exchanges, transfers: transfers} -} - -func (s *CustomerWalletServiceImpl) TransferRecipient(ctx context.Context, customerID uuid.UUID, phoneNumber string) *contract.Response { - recipient, err := s.transfers.Recipient(ctx, customerID, phoneNumber) - if err != nil { - return walletMoveErrorResponse(err) - } - return contract.BuildSuccessResponse(recipient) -} - -func (s *CustomerWalletServiceImpl) Transfer(ctx context.Context, customerID uuid.UUID, req *contract.TransferWalletRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response { - result, err := s.transfers.Transfer(ctx, customerID, models.WalletTransfer{ - Currency: req.Currency, - Amount: req.Amount, - RecipientPhone: req.RecipientPhone, - }, req.Pin, idempotencyKey, info) - if err != nil { - return walletMoveErrorResponse(err) - } - return contract.BuildSuccessResponse(result) -} - -func (s *CustomerWalletServiceImpl) PreviewExchange(ctx context.Context, customerID uuid.UUID, coins int64) *contract.Response { - preview, err := s.exchanges.Preview(ctx, customerID, coins) - if err != nil { - return walletMoveErrorResponse(err) - } - return contract.BuildSuccessResponse(preview) -} - -func (s *CustomerWalletServiceImpl) Exchange(ctx context.Context, customerID uuid.UUID, req *contract.ExchangeCoinsRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response { - result, err := s.exchanges.Exchange(ctx, customerID, req.Coins, req.Pin, idempotencyKey, info) - if err != nil { - return walletMoveErrorResponse(err) - } - return contract.BuildSuccessResponse(result) -} - -// walletMoveErrorResponse keeps the PIN codes the apps act on, and tells a refused -// request apart from a server failure. -func walletMoveErrorResponse(err error) *contract.Response { - var pinErr *processor.PinError - if errors.As(err, &pinErr) { - return PinErrorResponse(err) - } - code := constants.InternalServerErrorCode - switch { - case errors.Is(err, repository.ErrWalletNotFound), - errors.Is(err, processor.ErrWalletRecipientNotFound): - code = constants.NotFoundErrorCode - case errors.Is(err, processor.ErrWalletMoveRejected), - errors.Is(err, processor.ErrWalletIdempotencyConflict), - errors.Is(err, processor.ErrWalletInvalidEntry): - code = constants.ValidationErrorCode - } - return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(code, constants.WalletServiceEntity, err.Error()), - }) -} diff --git a/internal/service/earning_backfill_job.go b/internal/service/earning_backfill_job.go deleted file mode 100644 index 131434c..0000000 --- a/internal/service/earning_backfill_job.go +++ /dev/null @@ -1,74 +0,0 @@ -package service - -import ( - "context" - "sync" - "time" - - "apskel-pos-be/internal/logger" -) - -const ( - defaultEarningBackfillInterval = 30 * time.Minute - // How far back to look for paid orders that never earned. - earningBackfillWindow = 72 * time.Hour - // Orders looked at per run at most, so one run cannot run away. - earningBackfillMaxOrders = 5000 -) - -type missingEarner interface { - EarnMissing(ctx context.Context, since time.Time, maxOrders int) (checked, earned int, err error) -} - -// EarningBackfillJob is the safety net behind earning at payment time -// (docs/prd-point-coin.md F3, PC-203). Every run it earns for orders paid in the last -// few days that should have earned and did not, for example because the database was -// briefly unreachable right after the payment committed. -type EarningBackfillJob struct { - earner missingEarner - now func() time.Time - stopCh chan struct{} - stopOnce sync.Once -} - -func NewEarningBackfillJob(earner missingEarner) *EarningBackfillJob { - return &EarningBackfillJob{earner: earner, now: time.Now, stopCh: make(chan struct{})} -} - -func (j *EarningBackfillJob) Start(interval time.Duration) { - if interval <= 0 { - interval = defaultEarningBackfillInterval - } - go func() { - j.RunOnce(context.Background()) - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ticker.C: - j.RunOnce(context.Background()) - case <-j.stopCh: - return - } - } - }() - logger.NonContext.Infof("Earning backfill job started (interval: %s)", interval) -} - -func (j *EarningBackfillJob) Stop() { - j.stopOnce.Do(func() { close(j.stopCh) }) -} - -// RunOnce earns for every missed order in the window and reports how many it fixed. -// It is quiet when nothing was missed. -func (j *EarningBackfillJob) RunOnce(ctx context.Context) int { - checked, earned, err := j.earner.EarnMissing(ctx, j.now().Add(-earningBackfillWindow), earningBackfillMaxOrders) - if err != nil { - logger.NonContext.Error("Earning backfill failed to run", err) - } - if earned > 0 { - logger.NonContext.WarnWithFields("Earning backfill credited orders that had missed their earning", - map[string]interface{}{"checked": checked, "earned": earned}, nil) - } - return earned -} diff --git a/internal/service/earning_backfill_job_test.go b/internal/service/earning_backfill_job_test.go deleted file mode 100644 index 31146b5..0000000 --- a/internal/service/earning_backfill_job_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package service - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "apskel-pos-be/internal/logger" -) - -type missingEarnerFake struct { - since time.Time - max int - earned int - err error - calls int -} - -func (f *missingEarnerFake) EarnMissing(_ context.Context, since time.Time, maxOrders int) (int, int, error) { - f.calls++ - f.since, f.max = since, maxOrders - return f.earned * 2, f.earned, f.err -} - -func TestEarningBackfillJob(t *testing.T) { - logger.Setup("fatal", "json") - now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) - earner := &missingEarnerFake{earned: 3} - job := NewEarningBackfillJob(earner) - job.now = func() time.Time { return now } - - assert.Equal(t, 3, job.RunOnce(context.Background())) - assert.Equal(t, now.Add(-72*time.Hour), earner.since, "looks back three days") - assert.Equal(t, earningBackfillMaxOrders, earner.max) - - // A failing run is logged, not fatal. - earner.earned, earner.err = 0, errors.New("db down") - assert.Equal(t, 0, job.RunOnce(context.Background())) - - job.Start(time.Hour) - job.Stop() - job.Stop() -} diff --git a/internal/service/gamification_service.go b/internal/service/gamification_service.go index 7096fe1..6b5b22c 100644 --- a/internal/service/gamification_service.go +++ b/internal/service/gamification_service.go @@ -11,6 +11,26 @@ import ( ) type GamificationService interface { + // Customer Points + CreateCustomerPoints(ctx context.Context, req *contract.CreateCustomerPointsRequest) (*contract.CustomerPointsResponse, error) + GetCustomerPoints(ctx context.Context, id uuid.UUID) (*contract.CustomerPointsResponse, error) + GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*contract.CustomerPointsResponse, error) + ListCustomerPoints(ctx context.Context, query *contract.ListCustomerPointsRequest) (*contract.PaginatedCustomerPointsResponse, error) + UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerPointsRequest) (*contract.CustomerPointsResponse, error) + DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error + AddCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.AddCustomerPointsRequest) (*contract.CustomerPointsResponse, error) + DeductCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.DeductCustomerPointsRequest) (*contract.CustomerPointsResponse, error) + + // Customer Tokens + CreateCustomerTokens(ctx context.Context, req *contract.CreateCustomerTokensRequest) (*contract.CustomerTokensResponse, error) + GetCustomerTokens(ctx context.Context, id uuid.UUID) (*contract.CustomerTokensResponse, error) + GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*contract.CustomerTokensResponse, error) + ListCustomerTokens(ctx context.Context, query *contract.ListCustomerTokensRequest) (*contract.PaginatedCustomerTokensResponse, error) + UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerTokensRequest) (*contract.CustomerTokensResponse, error) + DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error + AddCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.AddCustomerTokensRequest) (*contract.CustomerTokensResponse, error) + DeductCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.DeductCustomerTokensRequest) (*contract.CustomerTokensResponse, error) + // Tiers CreateTier(ctx context.Context, req *contract.CreateTierRequest) (*contract.TierResponse, error) GetTier(ctx context.Context, id uuid.UUID) (*contract.TierResponse, error) @@ -52,14 +72,18 @@ type GamificationService interface { } type GamificationServiceImpl struct { - tierProcessor *processor.TierProcessor - gameProcessor *processor.GameProcessor - gamePrizeProcessor *processor.GamePrizeProcessor - gamePlayProcessor *processor.GamePlayProcessor - omsetTrackerProcessor *processor.OmsetTrackerProcessor + customerPointsProcessor *processor.CustomerPointsProcessor + customerTokensProcessor *processor.CustomerTokensProcessor + tierProcessor *processor.TierProcessor + gameProcessor *processor.GameProcessor + gamePrizeProcessor *processor.GamePrizeProcessor + gamePlayProcessor *processor.GamePlayProcessor + omsetTrackerProcessor *processor.OmsetTrackerProcessor } func NewGamificationService( + customerPointsProcessor *processor.CustomerPointsProcessor, + customerTokensProcessor *processor.CustomerTokensProcessor, tierProcessor *processor.TierProcessor, gameProcessor *processor.GameProcessor, gamePrizeProcessor *processor.GamePrizeProcessor, @@ -67,14 +91,144 @@ func NewGamificationService( omsetTrackerProcessor *processor.OmsetTrackerProcessor, ) *GamificationServiceImpl { return &GamificationServiceImpl{ - tierProcessor: tierProcessor, - gameProcessor: gameProcessor, - gamePrizeProcessor: gamePrizeProcessor, - gamePlayProcessor: gamePlayProcessor, - omsetTrackerProcessor: omsetTrackerProcessor, + customerPointsProcessor: customerPointsProcessor, + customerTokensProcessor: customerTokensProcessor, + tierProcessor: tierProcessor, + gameProcessor: gameProcessor, + gamePrizeProcessor: gamePrizeProcessor, + gamePlayProcessor: gamePlayProcessor, + omsetTrackerProcessor: omsetTrackerProcessor, } } +// Customer Points Service Methods +func (s *GamificationServiceImpl) CreateCustomerPoints(ctx context.Context, req *contract.CreateCustomerPointsRequest) (*contract.CustomerPointsResponse, error) { + modelReq := transformer.CreateCustomerPointsRequestToModel(req) + response, err := s.customerPointsProcessor.CreateCustomerPoints(ctx, modelReq) + if err != nil { + return nil, err + } + return transformer.CustomerPointsModelToResponse(response), nil +} + +func (s *GamificationServiceImpl) GetCustomerPoints(ctx context.Context, id uuid.UUID) (*contract.CustomerPointsResponse, error) { + response, err := s.customerPointsProcessor.GetCustomerPoints(ctx, id) + if err != nil { + return nil, err + } + return transformer.CustomerPointsModelToResponse(response), nil +} + +func (s *GamificationServiceImpl) GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*contract.CustomerPointsResponse, error) { + response, err := s.customerPointsProcessor.GetCustomerPointsByCustomerID(ctx, customerID) + if err != nil { + return nil, err + } + return transformer.CustomerPointsModelToResponse(response), nil +} + +func (s *GamificationServiceImpl) ListCustomerPoints(ctx context.Context, query *contract.ListCustomerPointsRequest) (*contract.PaginatedCustomerPointsResponse, error) { + modelQuery := transformer.ListCustomerPointsRequestToModel(query) + response, err := s.customerPointsProcessor.ListCustomerPoints(ctx, modelQuery) + if err != nil { + return nil, err + } + return transformer.PaginatedCustomerPointsResponseToContract(response), nil +} + +func (s *GamificationServiceImpl) UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerPointsRequest) (*contract.CustomerPointsResponse, error) { + modelReq := transformer.UpdateCustomerPointsRequestToModel(req) + response, err := s.customerPointsProcessor.UpdateCustomerPoints(ctx, id, modelReq) + if err != nil { + return nil, err + } + return transformer.CustomerPointsModelToResponse(response), nil +} + +func (s *GamificationServiceImpl) DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error { + return s.customerPointsProcessor.DeleteCustomerPoints(ctx, id) +} + +func (s *GamificationServiceImpl) AddCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.AddCustomerPointsRequest) (*contract.CustomerPointsResponse, error) { + response, err := s.customerPointsProcessor.AddPoints(ctx, customerID, req.Points) + if err != nil { + return nil, err + } + return transformer.CustomerPointsModelToResponse(response), nil +} + +func (s *GamificationServiceImpl) DeductCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.DeductCustomerPointsRequest) (*contract.CustomerPointsResponse, error) { + response, err := s.customerPointsProcessor.DeductPoints(ctx, customerID, req.Points) + if err != nil { + return nil, err + } + return transformer.CustomerPointsModelToResponse(response), nil +} + +// Customer Tokens Service Methods +func (s *GamificationServiceImpl) CreateCustomerTokens(ctx context.Context, req *contract.CreateCustomerTokensRequest) (*contract.CustomerTokensResponse, error) { + modelReq := transformer.CreateCustomerTokensRequestToModel(req) + response, err := s.customerTokensProcessor.CreateCustomerTokens(ctx, modelReq) + if err != nil { + return nil, err + } + return transformer.CustomerTokensModelToResponse(response), nil +} + +func (s *GamificationServiceImpl) GetCustomerTokens(ctx context.Context, id uuid.UUID) (*contract.CustomerTokensResponse, error) { + response, err := s.customerTokensProcessor.GetCustomerTokens(ctx, id) + if err != nil { + return nil, err + } + return transformer.CustomerTokensModelToResponse(response), nil +} + +func (s *GamificationServiceImpl) GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*contract.CustomerTokensResponse, error) { + response, err := s.customerTokensProcessor.GetCustomerTokensByCustomerIDAndType(ctx, customerID, tokenType) + if err != nil { + return nil, err + } + return transformer.CustomerTokensModelToResponse(response), nil +} + +func (s *GamificationServiceImpl) ListCustomerTokens(ctx context.Context, query *contract.ListCustomerTokensRequest) (*contract.PaginatedCustomerTokensResponse, error) { + modelQuery := transformer.ListCustomerTokensRequestToModel(query) + response, err := s.customerTokensProcessor.ListCustomerTokens(ctx, modelQuery) + if err != nil { + return nil, err + } + return transformer.PaginatedCustomerTokensResponseToContract(response), nil +} + +func (s *GamificationServiceImpl) UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerTokensRequest) (*contract.CustomerTokensResponse, error) { + modelReq := transformer.UpdateCustomerTokensRequestToModel(req) + response, err := s.customerTokensProcessor.UpdateCustomerTokens(ctx, id, modelReq) + if err != nil { + return nil, err + } + return transformer.CustomerTokensModelToResponse(response), nil +} + +func (s *GamificationServiceImpl) DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error { + return s.customerTokensProcessor.DeleteCustomerTokens(ctx, id) +} + +func (s *GamificationServiceImpl) AddCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.AddCustomerTokensRequest) (*contract.CustomerTokensResponse, error) { + response, err := s.customerTokensProcessor.AddTokens(ctx, customerID, tokenType, req.Tokens) + if err != nil { + return nil, err + } + return transformer.CustomerTokensModelToResponse(response), nil +} + +func (s *GamificationServiceImpl) DeductCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.DeductCustomerTokensRequest) (*contract.CustomerTokensResponse, error) { + response, err := s.customerTokensProcessor.DeductTokens(ctx, customerID, tokenType, req.Tokens) + if err != nil { + return nil, err + } + return transformer.CustomerTokensModelToResponse(response), nil +} + // Tier Service Methods func (s *GamificationServiceImpl) CreateTier(ctx context.Context, req *contract.CreateTierRequest) (*contract.TierResponse, error) { modelReq := transformer.CreateTierRequestToModel(req) diff --git a/internal/service/loyalty_settings_service.go b/internal/service/loyalty_settings_service.go deleted file mode 100644 index 1d81921..0000000 --- a/internal/service/loyalty_settings_service.go +++ /dev/null @@ -1,224 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - - "apskel-pos-be/internal/appcontext" - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" -) - -// LoyaltySettingsService is the dashboard's access to loyalty settings -// (docs/prd-point-coin.md F1, F2). Every call is scoped to the caller's organization. -type LoyaltySettingsService interface { - GetOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID) *contract.Response - // UpdateOutletSettings applies a JSON body on top of the current settings: fields - // left out keep their value, and null clears an optional limit. - UpdateOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID, body []byte) *contract.Response - - GetOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response - // UpdateOrganizationSettings applies a JSON body like UpdateOutletSettings. With - // dryRun it only validates and reports what would change and its impact. - UpdateOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo, body []byte, dryRun bool) *contract.Response - // ListHistory returns the settings history of the organization, or of one outlet. - ListHistory(ctx context.Context, apctx *appcontext.ContextInfo, outletID *uuid.UUID, page, limit int) *contract.Response -} - -type outstandingBalanceReader interface { - OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (points, coins int64, err error) -} - -type LoyaltySettingsServiceImpl struct { - settings *processor.LoyaltySettingsProcessor - outstanding outstandingBalanceReader -} - -func NewLoyaltySettingsService(settings *processor.LoyaltySettingsProcessor, outstanding outstandingBalanceReader) *LoyaltySettingsServiceImpl { - return &LoyaltySettingsServiceImpl{settings: settings, outstanding: outstanding} -} - -func (s *LoyaltySettingsServiceImpl) GetOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID) *contract.Response { - current, err := s.settings.OutletForOrganization(ctx, apctx.OrganizationID, outletID) - if err != nil { - return loyaltyErrorResponse(err) - } - view, err := s.outletView(ctx, apctx.OrganizationID, outletID, *current, nil) - if err != nil { - return loyaltyErrorResponse(err) - } - return contract.BuildSuccessResponse(view) -} - -// outletSettingsInput is what PUT accepts: the settings, plus the read-only fields of -// the GET response so a client can send back what it received. Those are ignored. -type outletSettingsInput struct { - *models.OutletLoyaltySettings - OutletID json.RawMessage `json:"outlet_id"` - PointValue json.RawMessage `json:"point_value"` - PointCashbackPercent json.RawMessage `json:"point_cashback_percent"` - Changes json.RawMessage `json:"changes"` -} - -func (s *LoyaltySettingsServiceImpl) UpdateOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID, body []byte) *contract.Response { - current, err := s.settings.OutletForOrganization(ctx, apctx.OrganizationID, outletID) - if err != nil { - return loyaltyErrorResponse(err) - } - - next := *current - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&outletSettingsInput{OutletLoyaltySettings: &next}); err != nil { - return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MalformedFieldErrorCode, constants.LoyaltySettingsServiceEntity, fmt.Sprintf("invalid request body: %v", err)), - }) - } - - changes, err := s.settings.UpdateOutlet(ctx, apctx.OrganizationID, outletID, apctx.UserID, next) - if err != nil { - return loyaltyErrorResponse(err) - } - saved, err := s.settings.Outlet(ctx, outletID) - if err != nil { - return loyaltyErrorResponse(err) - } - view, err := s.outletView(ctx, apctx.OrganizationID, outletID, *saved, changes) - if err != nil { - return loyaltyErrorResponse(err) - } - return contract.BuildSuccessResponse(view) -} - -func (s *LoyaltySettingsServiceImpl) outletView(ctx context.Context, organizationID, outletID uuid.UUID, settings models.OutletLoyaltySettings, changes []models.LoyaltySettingChange) (*models.OutletLoyaltySettingsView, error) { - pointValue, err := s.settings.PointValue(ctx, organizationID) - if err != nil { - return nil, err - } - if changes == nil { - changes = []models.LoyaltySettingChange{} - } - return &models.OutletLoyaltySettingsView{ - OutletID: outletID, - OutletLoyaltySettings: settings, - PointValue: pointValue, - PointCashbackPercent: models.LoyaltyCashbackPercent(settings.Point.EarnValue, pointValue, settings.Point.EarnPerAmount), - Changes: changes, - }, nil -} - -func loyaltyErrorResponse(err error) *contract.Response { - code, message := constants.InternalServerErrorCode, err.Error() - switch { - case errors.Is(err, repository.ErrLoyaltyOutletNotFound): - code, message = constants.NotFoundErrorCode, "outlet not found" - case errors.Is(err, processor.ErrInvalidLoyaltySettings): - code = constants.ValidationErrorCode - } - return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(code, constants.LoyaltySettingsServiceEntity, message), - }) -} - -func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response { - current, err := s.settings.Organization(ctx, apctx.OrganizationID) - if err != nil { - return loyaltyErrorResponse(err) - } - view, err := s.organizationView(ctx, apctx.OrganizationID, *current, *current, nil, nil, false) - if err != nil { - return loyaltyErrorResponse(err) - } - return contract.BuildSuccessResponse(view) -} - -// organizationSettingsInput is what PUT accepts: the settings plus the read-only fields -// of the GET response, which are ignored. -type organizationSettingsInput struct { - *models.OrganizationLoyaltySettings - Impact json.RawMessage `json:"impact"` - ExpiryPreview json.RawMessage `json:"expiry_preview"` - ExpiryActivations json.RawMessage `json:"expiry_activations"` - Changes json.RawMessage `json:"changes"` - DryRun json.RawMessage `json:"dry_run"` -} - -func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo, body []byte, dryRun bool) *contract.Response { - current, err := s.settings.Organization(ctx, apctx.OrganizationID) - if err != nil { - return loyaltyErrorResponse(err) - } - next := *current - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&organizationSettingsInput{OrganizationLoyaltySettings: &next}); err != nil { - return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MalformedFieldErrorCode, constants.LoyaltySettingsServiceEntity, fmt.Sprintf("invalid request body: %v", err)), - }) - } - - var changes []models.LoyaltySettingChange - var activations []models.LoyaltyExpiryActivation - if dryRun { - changes, activations, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next) - } else { - changes, activations, err = s.settings.UpdateOrganization(ctx, apctx.OrganizationID, apctx.UserID, next) - } - if err != nil { - return loyaltyErrorResponse(err) - } - after := next - if !dryRun { - saved, err := s.settings.Organization(ctx, apctx.OrganizationID) - if err != nil { - return loyaltyErrorResponse(err) - } - after = *saved - } - view, err := s.organizationView(ctx, apctx.OrganizationID, *current, after, changes, activations, dryRun) - if err != nil { - return loyaltyErrorResponse(err) - } - return contract.BuildSuccessResponse(view) -} - -func (s *LoyaltySettingsServiceImpl) ListHistory(ctx context.Context, apctx *appcontext.ContextInfo, outletID *uuid.UUID, page, limit int) *contract.Response { - history, err := s.settings.ListChanges(ctx, apctx.OrganizationID, outletID, page, limit) - if err != nil { - return loyaltyErrorResponse(err) - } - return contract.BuildSuccessResponse(history) -} - -func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, changes []models.LoyaltySettingChange, activations []models.LoyaltyExpiryActivation, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) { - now := time.Now() - points, coins, err := s.outstanding.OrganizationOutstanding(ctx, organizationID) - if err != nil { - return nil, err - } - if activations == nil { - activations = []models.LoyaltyExpiryActivation{} - } - if changes == nil { - changes = []models.LoyaltySettingChange{} - } - return &models.OrganizationLoyaltySettingsView{ - OrganizationLoyaltySettings: after, - Impact: models.NewLoyaltySettingsImpact(points, coins, before, after), - ExpiryPreview: models.LoyaltyExpiryPreview{ - Point: processor.ComputeExpiry(after.PointExpiry, now), - Coin: processor.ComputeExpiry(after.CoinExpiry, now), - }, - Changes: changes, - DryRun: dryRun, - }, nil -} diff --git a/internal/service/order_service.go b/internal/service/order_service.go index 626fb24..d7e450c 100644 --- a/internal/service/order_service.go +++ b/internal/service/order_service.go @@ -557,8 +557,7 @@ func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymen return fmt.Errorf("payment method ID is required") } - // A payment with EnakPoint gives points instead; its amount is computed from them. - if req.Points == nil && req.Amount <= 0 { + if req.Amount <= 0 { return fmt.Errorf("payment amount must be greater than zero") } diff --git a/internal/service/order_service_table_test.go b/internal/service/order_service_table_test.go index adbc497..66fd43b 100644 --- a/internal/service/order_service_table_test.go +++ b/internal/service/order_service_table_test.go @@ -42,14 +42,6 @@ func (m *MockOrderProcessor) UpdateOrder(ctx context.Context, id uuid.UUID, req return args.Get(0).(*models.OrderResponse), args.Error(1) } -func (m *MockOrderProcessor) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) { - args := m.Called(ctx, customerID, orderID, points, pin, info) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*models.PaymentResponse), args.Error(1) -} - func (m *MockOrderProcessor) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) { args := m.Called(ctx, id) if args.Get(0) == nil { diff --git a/internal/service/payment_method_service.go b/internal/service/payment_method_service.go index fa1198f..d0ecd7d 100644 --- a/internal/service/payment_method_service.go +++ b/internal/service/payment_method_service.go @@ -2,10 +2,8 @@ package service import ( "context" - "errors" "apskel-pos-be/internal/appcontext" - "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/processor" @@ -41,7 +39,7 @@ func (s *PaymentMethodServiceImpl) CreatePaymentMethod(ctx context.Context, cont response, err := s.paymentMethodProcessor.CreatePaymentMethod(ctx, modelReq) if err != nil { return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_CREATE_ERROR"), "payment_method", err.Error()), + contract.NewResponseError("PAYMENT_METHOD_CREATE_ERROR", "payment_method", err.Error()), }) } @@ -86,7 +84,7 @@ func (s *PaymentMethodServiceImpl) UpdatePaymentMethod(ctx context.Context, id u response, err := s.paymentMethodProcessor.UpdatePaymentMethod(ctx, id, modelReq) if err != nil { return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_UPDATE_ERROR"), "payment_method", err.Error()), + contract.NewResponseError("PAYMENT_METHOD_UPDATE_ERROR", "payment_method", err.Error()), }) } @@ -99,7 +97,7 @@ func (s *PaymentMethodServiceImpl) DeletePaymentMethod(ctx context.Context, id u err := s.paymentMethodProcessor.DeletePaymentMethod(ctx, id) if err != nil { return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_DELETE_ERROR"), "payment_method", err.Error()), + contract.NewResponseError("PAYMENT_METHOD_DELETE_ERROR", "payment_method", err.Error()), }) } @@ -125,12 +123,3 @@ func (s *PaymentMethodServiceImpl) GetActivePaymentMethodsByOrganization(ctx con return contract.BuildSuccessResponse(contractResponses) } - -// paymentMethodErrorCode answers a request to create, delete or retype the EnakPoint -// method as a bad request instead of a server error. -func paymentMethodErrorCode(err error, fallback string) string { - if errors.Is(err, processor.ErrSystemPaymentMethod) { - return constants.ValidationErrorCode - } - return fallback -} diff --git a/internal/service/point_payment_service.go b/internal/service/point_payment_service.go deleted file mode 100644 index a0c609e..0000000 --- a/internal/service/point_payment_service.go +++ /dev/null @@ -1,55 +0,0 @@ -package service - -import ( - "context" - "errors" - - "github.com/google/uuid" - - "apskel-pos-be/internal/appcontext" - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" -) - -// PointPaymentService serves what the cashier needs before taking EnakPoint -// (docs/prd-point-coin.md F9). -type PointPaymentService interface { - Preview(ctx context.Context, apctx *appcontext.ContextInfo, orderID uuid.UUID) *contract.Response -} - -type PointPaymentServiceImpl struct { - payments *processor.PointPaymentProcessor -} - -func NewPointPaymentService(payments *processor.PointPaymentProcessor) *PointPaymentServiceImpl { - return &PointPaymentServiceImpl{payments: payments} -} - -func (s *PointPaymentServiceImpl) Preview(ctx context.Context, apctx *appcontext.ContextInfo, orderID uuid.UUID) *contract.Response { - preview, err := s.payments.Preview(ctx, apctx.OrganizationID, orderID) - if err != nil { - return PointPaymentErrorResponse(err) - } - return contract.BuildSuccessResponse(preview) -} - -// PointPaymentErrorResponse answers a refused EnakPoint payment as a bad request, PIN -// problems with their own codes, and anything else as a server error. -func PointPaymentErrorResponse(err error) *contract.Response { - var pinErr *processor.PinError - if errors.As(err, &pinErr) { - return PinErrorResponse(err) - } - code := constants.InternalServerErrorCode - switch { - case errors.Is(err, repository.ErrPointPaymentOrderNotFound): - code = constants.NotFoundErrorCode - case errors.Is(err, processor.ErrPointPaymentRejected): - code = constants.ValidationErrorCode - } - return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(code, constants.WalletServiceEntity, err.Error()), - }) -} diff --git a/internal/service/spin_game_service.go b/internal/service/spin_game_service.go index 42390c2..dcfd93e 100644 --- a/internal/service/spin_game_service.go +++ b/internal/service/spin_game_service.go @@ -6,6 +6,7 @@ import ( "apskel-pos-be/internal/models" "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" "github.com/google/uuid" ) @@ -16,11 +17,13 @@ type SpinGameService interface { type spinGameService struct { gamePlayProcessor *processor.GamePlayProcessor + txManager *repository.TxManager } -func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor) SpinGameService { +func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor, txManager *repository.TxManager) SpinGameService { return &spinGameService{ gamePlayProcessor: gamePlayProcessor, + txManager: txManager, } } @@ -42,13 +45,28 @@ func (s *spinGameService) PlaySpinGame(ctx context.Context, customerID string, r return nil, fmt.Errorf("invalid customer ID format: %w", err) } - // PlayGame runs its own transaction and charges the game's coin_cost in EnakCoin. - playResponse, err := s.gamePlayProcessor.PlayGame(ctx, &models.PlayGameRequest{ - GameID: spinID, - CustomerID: customerUUID, + // Use transaction to ensure consistency + var playResponse *models.PlayGameResponse + err = s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { + // Create play game request + playGameReq := &models.PlayGameRequest{ + GameID: spinID, + CustomerID: customerUUID, + TokenUsed: 1, // Default to 1 token per spin + } + + // Play the game within transaction + var playErr error + playResponse, playErr = s.gamePlayProcessor.PlayGame(txCtx, playGameReq) + if playErr != nil { + return fmt.Errorf("failed to play spin game: %w", playErr) + } + + return nil }) + if err != nil { - return nil, fmt.Errorf("failed to play spin game: %w", err) + return nil, err } // Convert prize to customer-facing format @@ -70,9 +88,9 @@ func (s *spinGameService) PlaySpinGame(ctx context.Context, customerID string, r Status: "SUCCESS", Message: "Spin game completed successfully.", Data: &models.SpinGameResponseData{ - GamePlay: playResponse.GamePlay, - PrizeWon: customerPrize, - CoinsRemaining: playResponse.CoinsRemaining, + GamePlay: playResponse.GamePlay, + PrizeWon: customerPrize, + TokensRemaining: playResponse.TokensRemaining, }, } diff --git a/internal/service/wallet_admin_service.go b/internal/service/wallet_admin_service.go deleted file mode 100644 index c29580d..0000000 --- a/internal/service/wallet_admin_service.go +++ /dev/null @@ -1,82 +0,0 @@ -package service - -import ( - "context" - "errors" - - "github.com/google/uuid" - - "apskel-pos-be/internal/appcontext" - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" -) - -// WalletAdminService is the dashboard's access to a customer's wallet -// (docs/prd-point-coin.md F7). Every call is scoped to the caller's organization. -type WalletAdminService interface { - GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response - AdjustCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.AdjustCustomerWalletRequest) *contract.Response - // TraceTransaction follows a ledger row lot by lot back to where its balance came from. - TraceTransaction(ctx context.Context, apctx *appcontext.ContextInfo, transactionID uuid.UUID) *contract.Response -} - -type WalletAdminServiceImpl struct { - walletAdminProcessor *processor.WalletAdminProcessor - walletTraceProcessor *processor.WalletTraceProcessor -} - -func NewWalletAdminService(walletAdminProcessor *processor.WalletAdminProcessor, walletTraceProcessor *processor.WalletTraceProcessor) *WalletAdminServiceImpl { - return &WalletAdminServiceImpl{walletAdminProcessor: walletAdminProcessor, walletTraceProcessor: walletTraceProcessor} -} - -func (s *WalletAdminServiceImpl) TraceTransaction(ctx context.Context, apctx *appcontext.ContextInfo, transactionID uuid.UUID) *contract.Response { - trace, err := s.walletTraceProcessor.Trace(ctx, apctx.OrganizationID, transactionID) - if err != nil { - return walletErrorResponse(err) - } - return contract.BuildSuccessResponse(trace) -} - -func (s *WalletAdminServiceImpl) GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response { - wallet, err := s.walletAdminProcessor.GetCustomerWallet(ctx, apctx.OrganizationID, customerID, query) - if err != nil { - return walletErrorResponse(err) - } - return contract.BuildSuccessResponse(wallet) -} - -func (s *WalletAdminServiceImpl) AdjustCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.AdjustCustomerWalletRequest) *contract.Response { - result, err := s.walletAdminProcessor.Adjust(ctx, apctx.OrganizationID, apctx.UserID, customerID, models.WalletAdjustment{ - Currency: req.Currency, - Amount: req.Amount, - Reason: req.Reason, - IdempotencyKey: req.IdempotencyKey, - }) - if err != nil { - return walletErrorResponse(err) - } - return contract.BuildSuccessResponse(result) -} - -// walletErrorResponse tells the dashboard's mistakes apart from server failures. -func walletErrorResponse(err error) *contract.Response { - code, message := constants.InternalServerErrorCode, err.Error() - switch { - case errors.Is(err, repository.ErrWalletNotFound): - code, message = constants.NotFoundErrorCode, "customer not found" - case errors.Is(err, repository.ErrWalletTransactionNotFound): - code, message = constants.NotFoundErrorCode, "wallet transaction not found" - case errors.Is(err, repository.ErrWalletInsufficientBalance): - code, message = constants.ValidationErrorCode, "the adjustment would take more than the customer can spend" - case errors.Is(err, processor.ErrInvalidWalletQuery), - errors.Is(err, processor.ErrWalletInvalidEntry), - errors.Is(err, processor.ErrWalletIdempotencyConflict): - code = constants.ValidationErrorCode - } - return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(code, constants.WalletServiceEntity, message), - }) -} diff --git a/internal/service/wallet_expiry_job.go b/internal/service/wallet_expiry_job.go deleted file mode 100644 index f2a83c0..0000000 --- a/internal/service/wallet_expiry_job.go +++ /dev/null @@ -1,77 +0,0 @@ -package service - -import ( - "context" - "sync" - "time" - - "apskel-pos-be/internal/logger" -) - -// Lots expire at the end of their day, so running every quarter of an hour keeps any -// lot from staying past its expiry for more than about that long (PC-503). -const defaultWalletExpiryInterval = 15 * time.Minute - -type walletExpiryWork interface { - ExpireDue(ctx context.Context) (int, error) - SendReminders(ctx context.Context) (int, error) -} - -// WalletExpiryJob expires the balances whose time is up and reminds customers of what -// is about to (docs/prd-point-coin.md F12, PC-503, PC-504). -// Unlike OmsetMilestoneScheduler it keeps no state in memory: several instances can -// run it at once, and a restart repeats nothing, because every lot is expired under -// its wallet's lock with an idempotency key. -type WalletExpiryJob struct { - expirer walletExpiryWork - stopCh chan struct{} - stopOnce sync.Once -} - -func NewWalletExpiryJob(expirer walletExpiryWork) *WalletExpiryJob { - return &WalletExpiryJob{expirer: expirer, stopCh: make(chan struct{})} -} - -func (j *WalletExpiryJob) Start(interval time.Duration) { - if interval <= 0 { - interval = defaultWalletExpiryInterval - } - go func() { - j.RunOnce(context.Background()) - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ticker.C: - j.RunOnce(context.Background()) - case <-j.stopCh: - return - } - } - }() - logger.NonContext.Infof("Wallet expiry job started (interval: %s)", interval) -} - -func (j *WalletExpiryJob) Stop() { - j.stopOnce.Do(func() { close(j.stopCh) }) -} - -// RunOnce expires what is due, sends the reminders that are due, and returns how many -// lots it expired. -func (j *WalletExpiryJob) RunOnce(ctx context.Context) int { - expired, err := j.expirer.ExpireDue(ctx) - if err != nil { - logger.NonContext.Error("Wallet expiry failed to run", err) - } - if expired > 0 { - logger.NonContext.Infof("Wallet expiry expired %d lots", expired) - } - reminded, err := j.expirer.SendReminders(ctx) - if err != nil { - logger.NonContext.Error("Wallet expiry reminders failed to run", err) - } - if reminded > 0 { - logger.NonContext.Infof("Wallet expiry sent %d reminders", reminded) - } - return expired -} diff --git a/internal/service/wallet_reconciliation_job.go b/internal/service/wallet_reconciliation_job.go deleted file mode 100644 index 2b9aebd..0000000 --- a/internal/service/wallet_reconciliation_job.go +++ /dev/null @@ -1,212 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "sort" - "sync" - "time" - - "github.com/google/uuid" - - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -const ( - defaultWalletReconciliationInterval = 6 * time.Hour - // Per check, so one systematic bug cannot flood the log or the notification. - walletReconciliationLimit = 50 -) - -type walletDiscrepancyFinder interface { - FindDiscrepancies(ctx context.Context, limit int) ([]repository.WalletDiscrepancy, error) -} - -type organizationUserLister interface { - GetByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error) -} - -type notificationSender interface { - Send(ctx context.Context, req *models.SendNotificationRequest) (*models.NotificationResponse, error) -} - -// WalletReconciliationJob periodically runs the §7.5 checks of -// docs/prd-point-coin.md over every wallet (PC-108). It is silent while the data is -// consistent. When it finds a discrepancy it logs each one and notifies the admins, -// owners and managers of the organization concerned. -// -// An organization is notified again only when its set of discrepancies changes, so an -// unfixed problem does not page the same people every run. That memory is in-process: -// a restart notifies once more, and each running instance keeps its own. -type WalletReconciliationJob struct { - finder walletDiscrepancyFinder - users organizationUserLister - notifier notificationSender - - mu sync.Mutex - notified map[uuid.UUID]string // organization -> fingerprint last notified - stopCh chan struct{} - stopOnce sync.Once -} - -func NewWalletReconciliationJob(finder walletDiscrepancyFinder, users organizationUserLister, notifier notificationSender) *WalletReconciliationJob { - return &WalletReconciliationJob{ - finder: finder, - users: users, - notifier: notifier, - notified: make(map[uuid.UUID]string), - stopCh: make(chan struct{}), - } -} - -// Start runs the checks once now and then every interval, in the background. -func (j *WalletReconciliationJob) Start(interval time.Duration) { - if interval <= 0 { - interval = defaultWalletReconciliationInterval - } - go func() { - j.runLogged() - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ticker.C: - j.runLogged() - case <-j.stopCh: - return - } - } - }() - logger.NonContext.Infof("Wallet reconciliation job started (interval: %s)", interval) -} - -func (j *WalletReconciliationJob) Stop() { - j.stopOnce.Do(func() { close(j.stopCh) }) -} - -func (j *WalletReconciliationJob) runLogged() { - if _, err := j.RunOnce(context.Background()); err != nil { - logger.NonContext.Error("Wallet reconciliation failed to run", err) - } -} - -// RunOnce runs every check, reports what it finds, and returns it. -func (j *WalletReconciliationJob) RunOnce(ctx context.Context) ([]repository.WalletDiscrepancy, error) { - found, err := j.finder.FindDiscrepancies(ctx, walletReconciliationLimit) - if err != nil { - return nil, err - } - - byOrg := make(map[uuid.UUID][]repository.WalletDiscrepancy) - for _, d := range found { - fields := map[string]interface{}{ - "check": d.Check, - "organization_id": d.OrganizationID.String(), - "customer_id": d.CustomerID.String(), - "currency": d.Currency, - "expected": d.Expected, - "actual": d.Actual, - } - if d.ObjectID != nil { - fields["object_id"] = d.ObjectID.String() - } - logger.NonContext.WarnWithFields("Wallet reconciliation found a discrepancy", fields, nil) - byOrg[d.OrganizationID] = append(byOrg[d.OrganizationID], d) - } - - j.mu.Lock() - defer j.mu.Unlock() - // Organizations that are clean again are forgotten, so a later problem notifies. - for org := range j.notified { - if _, still := byOrg[org]; !still { - delete(j.notified, org) - } - } - for org, discrepancies := range byOrg { - fingerprint := walletDiscrepancyFingerprint(discrepancies) - if j.notified[org] == fingerprint { - continue - } - if err := j.notify(ctx, org, discrepancies); err != nil { - logger.NonContext.Error(fmt.Sprintf("Wallet reconciliation could not notify organization %s", org), err) - continue - } - j.notified[org] = fingerprint - } - return found, nil -} - -func (j *WalletReconciliationJob) notify(ctx context.Context, organizationID uuid.UUID, discrepancies []repository.WalletDiscrepancy) error { - if organizationID == uuid.Nil { - return fmt.Errorf("discrepancy without an organization") - } - users, err := j.users.GetByOrganizationID(ctx, organizationID) - if err != nil { - return err - } - var receivers []uuid.UUID - for _, u := range users { - switch u.Role { - case entities.RoleAdmin, entities.RoleOwner, entities.RoleManager: - receivers = append(receivers, u.ID) - } - } - if len(receivers) == 0 { - return nil - } - - perCheck := map[string]int{} - customers := map[string]bool{} - for _, d := range discrepancies { - perCheck[d.Check]++ - customers[d.CustomerID.String()] = true - } - customerIDs := make([]string, 0, len(customers)) - for id := range customers { - customerIDs = append(customerIDs, id) - } - sort.Strings(customerIDs) - - _, err = j.notifier.Send(ctx, &models.SendNotificationRequest{ - Title: "Selisih saldo EnakPoint/EnakCoin terdeteksi", - Body: fmt.Sprintf("Pemeriksaan rutin menemukan %d selisih pada saldo %d customer. Saldo belum dikoreksi otomatis; tim teknis perlu memeriksanya.", - len(discrepancies), len(customerIDs)), - Type: "system", - Category: "wallet_reconciliation", - Priority: entities.NotificationPriorityHigh, - NotifiableType: "organization", - NotifiableID: &organizationID, - ReceiverIDs: receivers, - Data: map[string]interface{}{ - "organization_id": organizationID.String(), - "discrepancies": len(discrepancies), - "per_check": perCheck, - "customer_ids": customerIDs, - }, - }) - return err -} - -// walletDiscrepancyFingerprint identifies a set of discrepancies regardless of order. -func walletDiscrepancyFingerprint(discrepancies []repository.WalletDiscrepancy) string { - keys := make([]string, 0, len(discrepancies)) - for _, d := range discrepancies { - object := "" - if d.ObjectID != nil { - object = d.ObjectID.String() - } - keys = append(keys, fmt.Sprintf("%s|%s|%s|%s|%d|%d", d.Check, d.CustomerID, d.Currency, object, d.Expected, d.Actual)) - } - sort.Strings(keys) - h := sha256.New() - for _, k := range keys { - h.Write([]byte(k)) - h.Write([]byte{'\n'}) - } - return hex.EncodeToString(h.Sum(nil)) -} diff --git a/internal/service/wallet_reconciliation_job_test.go b/internal/service/wallet_reconciliation_job_test.go deleted file mode 100644 index c12c8e6..0000000 --- a/internal/service/wallet_reconciliation_job_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package service - -import ( - "context" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -type discrepancyFinderFake struct { - found []repository.WalletDiscrepancy -} - -func (f *discrepancyFinderFake) FindDiscrepancies(context.Context, int) ([]repository.WalletDiscrepancy, error) { - return f.found, nil -} - -type orgUsersFake map[uuid.UUID][]*entities.User - -func (f orgUsersFake) GetByOrganizationID(_ context.Context, org uuid.UUID) ([]*entities.User, error) { - return f[org], nil -} - -type notifierFake struct { - sent []*models.SendNotificationRequest -} - -func (f *notifierFake) Send(_ context.Context, req *models.SendNotificationRequest) (*models.NotificationResponse, error) { - f.sent = append(f.sent, req) - return &models.NotificationResponse{}, nil -} - -func TestWalletReconciliationJob(t *testing.T) { - logger.Setup("fatal", "json") - org := uuid.New() - admin, owner, manager, cashier := uuid.New(), uuid.New(), uuid.New(), uuid.New() - users := orgUsersFake{org: { - {ID: admin, Role: entities.RoleAdmin}, - {ID: owner, Role: entities.RoleOwner}, - {ID: manager, Role: entities.RoleManager}, - {ID: cashier, Role: entities.RoleCashier}, - }} - finder := &discrepancyFinderFake{} - notifier := ¬ifierFake{} - job := NewWalletReconciliationJob(finder, users, notifier) - ctx := context.Background() - - // Consistent data: nothing reported. - found, err := job.RunOnce(ctx) - require.NoError(t, err) - assert.Empty(t, found) - assert.Empty(t, notifier.sent) - - // A discrepancy notifies the organization's admins, owners and managers. - customer := uuid.New() - lot := uuid.New() - finder.found = []repository.WalletDiscrepancy{ - {Check: repository.WalletCheckBalanceVsLots, OrganizationID: org, CustomerID: customer, Currency: "POINT", Expected: 100, Actual: 105}, - {Check: repository.WalletCheckLotVsAllocations, OrganizationID: org, CustomerID: customer, Currency: "POINT", ObjectID: &lot, Expected: 50, Actual: 55}, - } - found, err = job.RunOnce(ctx) - require.NoError(t, err) - assert.Len(t, found, 2) - require.Len(t, notifier.sent, 1) - sent := notifier.sent[0] - assert.ElementsMatch(t, []uuid.UUID{admin, owner, manager}, sent.ReceiverIDs, "cashiers are not told") - assert.Equal(t, &org, sent.NotifiableID) - assert.Equal(t, 2, sent.Data["discrepancies"]) - assert.Equal(t, []string{customer.String()}, sent.Data["customer_ids"]) - assert.Equal(t, entities.NotificationPriorityHigh, sent.Priority) - - // The same problem, still unfixed and in a different order, does not notify again. - finder.found = []repository.WalletDiscrepancy{finder.found[1], finder.found[0]} - _, err = job.RunOnce(ctx) - require.NoError(t, err) - assert.Len(t, notifier.sent, 1) - - // A changed problem does. - finder.found = finder.found[:1] - _, err = job.RunOnce(ctx) - require.NoError(t, err) - assert.Len(t, notifier.sent, 2) - - // Once clean the organization is forgotten, so the same problem coming back - // notifies again. - previous := finder.found - finder.found = nil - _, err = job.RunOnce(ctx) - require.NoError(t, err) - assert.Len(t, notifier.sent, 2) - finder.found = previous - _, err = job.RunOnce(ctx) - require.NoError(t, err) - assert.Len(t, notifier.sent, 3) -} - -func TestWalletReconciliationJobStartStop(t *testing.T) { - logger.Setup("fatal", "json") - job := NewWalletReconciliationJob(&discrepancyFinderFake{}, orgUsersFake{}, ¬ifierFake{}) - job.Start(0) - job.Stop() - job.Stop() // stopping twice is harmless -} diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index a6267cc..8590c18 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -60,8 +60,6 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe OrderCount: item.OrderCount, PaymentCount: item.PaymentCount, Percentage: item.Percentage, - PointsUsed: item.PointsUsed, - CountsAsCashIn: item.CountsAsCashIn, }) } @@ -74,9 +72,6 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe GroupBy: resp.GroupBy, Summary: contract.PaymentMethodSummary{ TotalAmount: resp.Summary.TotalAmount, - PointAmount: resp.Summary.PointAmount, - PointsUsed: resp.Summary.PointsUsed, - TotalWithPoints: resp.Summary.TotalWithPoints, TotalOrders: resp.Summary.TotalOrders, TotalPayments: resp.Summary.TotalPayments, AverageOrderValue: resp.Summary.AverageOrderValue, @@ -622,8 +617,6 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse) OrderCount: item.OrderCount, PaymentCount: item.PaymentCount, Percentage: item.Percentage, - PointsUsed: item.PointsUsed, - CountsAsCashIn: item.CountsAsCashIn, }) } diff --git a/internal/transformer/gamification_transformer.go b/internal/transformer/gamification_transformer.go index 0b66a63..b7a2b70 100644 --- a/internal/transformer/gamification_transformer.go +++ b/internal/transformer/gamification_transformer.go @@ -5,6 +5,131 @@ import ( "apskel-pos-be/internal/models" ) +// Customer Points Transformers +func CreateCustomerPointsRequestToModel(req *contract.CreateCustomerPointsRequest) *models.CreateCustomerPointsRequest { + return &models.CreateCustomerPointsRequest{ + CustomerID: req.CustomerID, + Balance: req.Balance, + } +} + +func UpdateCustomerPointsRequestToModel(req *contract.UpdateCustomerPointsRequest) *models.UpdateCustomerPointsRequest { + return &models.UpdateCustomerPointsRequest{ + Balance: req.Balance, + } +} + +func ListCustomerPointsRequestToModel(req *contract.ListCustomerPointsRequest) *models.ListCustomerPointsQuery { + return &models.ListCustomerPointsQuery{ + Page: req.Page, + Limit: req.Limit, + Search: req.Search, + SortBy: req.SortBy, + SortOrder: req.SortOrder, + } +} + +func CustomerPointsModelToResponse(model *models.CustomerPointsResponse) *contract.CustomerPointsResponse { + if model == nil { + return nil + } + + var customer *contract.CustomerResponse + if model.Customer != nil { + customer = CustomerModelToResponse(model.Customer) + } + + return &contract.CustomerPointsResponse{ + ID: model.ID, + CustomerID: model.CustomerID, + Balance: model.Balance, + Customer: customer, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + } +} + +func PaginatedCustomerPointsResponseToContract(model *models.PaginatedCustomerPointsResponse) *contract.PaginatedCustomerPointsResponse { + if model == nil { + return nil + } + + responses := make([]contract.CustomerPointsResponse, len(model.Data)) + for i, item := range model.Data { + responses[i] = *CustomerPointsModelToResponse(&item) + } + + return &contract.PaginatedCustomerPointsResponse{ + Data: responses, + TotalCount: model.TotalCount, + Page: model.Page, + Limit: model.Limit, + TotalPages: model.TotalPages, + } +} + +// Customer Tokens Transformers +func CreateCustomerTokensRequestToModel(req *contract.CreateCustomerTokensRequest) *models.CreateCustomerTokensRequest { + return &models.CreateCustomerTokensRequest{ + CustomerID: req.CustomerID, + TokenType: req.TokenType, + Balance: req.Balance, + } +} + +func UpdateCustomerTokensRequestToModel(req *contract.UpdateCustomerTokensRequest) *models.UpdateCustomerTokensRequest { + return &models.UpdateCustomerTokensRequest{ + Balance: req.Balance, + } +} + +func ListCustomerTokensRequestToModel(req *contract.ListCustomerTokensRequest) *models.ListCustomerTokensQuery { + return &models.ListCustomerTokensQuery{ + Page: req.Page, + Limit: req.Limit, + Search: req.Search, + TokenType: req.TokenType, + SortBy: req.SortBy, + SortOrder: req.SortOrder, + } +} + +func CustomerTokensModelToResponse(model *models.CustomerTokensResponse) *contract.CustomerTokensResponse { + if model == nil { + return nil + } + + var customer *contract.CustomerResponse + if model.Customer != nil { + customer = CustomerModelToResponse(model.Customer) + } + + return &contract.CustomerTokensResponse{ + ID: model.ID, + CustomerID: model.CustomerID, + TokenType: model.TokenType, + Balance: model.Balance, + Customer: customer, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + } +} + +func PaginatedCustomerTokensResponseToContract(model *models.PaginatedResponse[models.CustomerTokensResponse]) *contract.PaginatedCustomerTokensResponse { + responses := make([]contract.CustomerTokensResponse, len(model.Data)) + for i, item := range model.Data { + responses[i] = *CustomerTokensModelToResponse(&item) + } + + return &contract.PaginatedCustomerTokensResponse{ + Data: responses, + TotalCount: int(model.Pagination.Total), + Page: model.Pagination.Page, + Limit: model.Pagination.Limit, + TotalPages: model.Pagination.TotalPages, + } +} + // Tier Transformers func CreateTierRequestToModel(req *contract.CreateTierRequest) *models.CreateTierRequest { return &models.CreateTierRequest{ @@ -225,7 +350,7 @@ func CreateGamePlayRequestToModel(req *contract.CreateGamePlayRequest) *models.C return &models.CreateGamePlayRequest{ GameID: req.GameID, CustomerID: req.CustomerID, - CoinsUsed: req.CoinsUsed, + TokenUsed: req.TokenUsed, RandomSeed: req.RandomSeed, } } @@ -234,6 +359,7 @@ func PlayGameRequestToModel(req *contract.PlayGameRequest) *models.PlayGameReque return &models.PlayGameRequest{ GameID: req.GameID, CustomerID: req.CustomerID, + TokenUsed: req.TokenUsed, } } @@ -275,8 +401,7 @@ func GamePlayModelToResponse(model *models.GamePlayResponse) *contract.GamePlayR GameID: model.GameID, CustomerID: model.CustomerID, PrizeID: model.PrizeID, - CoinsUsed: model.CoinsUsed, - TokenUsed: model.CoinsUsed, + TokenUsed: model.TokenUsed, RandomSeed: model.RandomSeed, CreatedAt: model.CreatedAt, Game: game, @@ -308,8 +433,7 @@ func PlayGameModelToResponse(model *models.PlayGameResponse) *contract.PlayGameR return &contract.PlayGameResponse{ GamePlay: gamePlayValue, PrizeWon: prizeWon, - CoinsRemaining: model.CoinsRemaining, - TokensRemaining: model.CoinsRemaining, + TokensRemaining: model.TokensRemaining, } } diff --git a/internal/transformer/order_transformer.go b/internal/transformer/order_transformer.go index 5bd19ba..b297cef 100644 --- a/internal/transformer/order_transformer.go +++ b/internal/transformer/order_transformer.go @@ -161,8 +161,6 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse { TotalPaid: resp.TotalPaid, PaymentCount: resp.PaymentCount, SplitType: resp.SplitType, - PointsEarned: resp.PointsEarned, - CoinsEarned: resp.CoinsEarned, } } @@ -324,8 +322,6 @@ func CreatePaymentContractToModel(req *contract.CreatePaymentRequest) *models.Cr return &models.CreatePaymentRequest{ OrderID: req.OrderID, PaymentMethodID: req.PaymentMethodID, - Points: req.Points, - PaymentCode: req.PaymentCode, Amount: req.Amount, TransactionID: req.TransactionID, SplitNumber: req.SplitNumber, @@ -367,8 +363,6 @@ func PaymentModelToContract(resp *models.PaymentResponse) *contract.PaymentRespo SplitType: resp.SplitType, SplitDescription: resp.SplitDescription, RefundAmount: resp.RefundAmount, - PointsUsed: resp.PointsUsed, - PointValue: resp.PointValue, RefundReason: resp.RefundReason, RefundedAt: resp.RefundedAt, RefundedBy: resp.RefundedBy, diff --git a/internal/transformer/order_transformer_test.go b/internal/transformer/order_transformer_test.go index 5c55afd..c3067a0 100644 --- a/internal/transformer/order_transformer_test.go +++ b/internal/transformer/order_transformer_test.go @@ -1,14 +1,11 @@ package transformer import ( - "encoding/json" "testing" "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/models" "github.com/google/uuid" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -49,14 +46,3 @@ func TestAddToOrderContractToModelCarriesWeight(t *testing.T) { require.NotNil(t, result.OrderItems[0].Weight) require.Equal(t, weight, *result.OrderItems[0].Weight) } - -func TestOrderModelToContractCarriesEarnings(t *testing.T) { - got := OrderModelToContract(&models.OrderResponse{PointsEarned: 875, CoinsEarned: 3}) - assert.Equal(t, int64(875), got.PointsEarned) - assert.Equal(t, int64(3), got.CoinsEarned) - - body, err := json.Marshal(OrderModelToContract(&models.OrderResponse{})) - assert.NoError(t, err) - assert.Contains(t, string(body), `"points_earned":0`, "present as 0 when nothing was earned") - assert.Contains(t, string(body), `"coins_earned":0`) -} diff --git a/internal/validator/campaign_validator.go b/internal/validator/campaign_validator.go index 8618fc8..d46bae7 100644 --- a/internal/validator/campaign_validator.go +++ b/internal/validator/campaign_validator.go @@ -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, COINS, MIXED"), constants.ValidationErrorCode + return errors.New("invalid campaign type. Valid types are: REWARD, POINTS, TOKENS, 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, COINS, MIXED"), constants.ValidationErrorCode + return errors.New("invalid campaign type. Valid types are: REWARD, POINTS, TOKENS, 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, COINS, MIXED"), constants.ValidationErrorCode + return errors.New("invalid campaign type filter. Valid types are: REWARD, POINTS, TOKENS, 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, COINS, REWARD"), constants.ValidationErrorCode + return errors.New("invalid reward type filter. Valid types are: POINTS, TOKENS, 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", "COINS", "TOKENS", "MIXED"} + validTypes := []string{"REWARD", "POINTS", "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", "COINS", "TOKENS", "REWARD"} + validTypes := []string{"POINTS", "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, COINS, REWARD") + return errors.New("invalid reward type in rule " + string(rune(ruleNumber)) + ". Valid types are: POINTS, TOKENS, REWARD") } // Validate reward value based on reward type - if rule.RewardType == "POINTS" || rule.RewardType == "COINS" || rule.RewardType == "TOKENS" { + if rule.RewardType == "POINTS" || 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))) } diff --git a/internal/validator/gamification_validator.go b/internal/validator/gamification_validator.go index e0fc023..c65a001 100644 --- a/internal/validator/gamification_validator.go +++ b/internal/validator/gamification_validator.go @@ -9,6 +9,20 @@ import ( ) type GamificationValidator interface { + // Customer Points + ValidateCreateCustomerPointsRequest(req *contract.CreateCustomerPointsRequest) (error, string) + ValidateUpdateCustomerPointsRequest(req *contract.UpdateCustomerPointsRequest) (error, string) + ValidateListCustomerPointsRequest(req *contract.ListCustomerPointsRequest) (error, string) + ValidateAddCustomerPointsRequest(req *contract.AddCustomerPointsRequest) (error, string) + ValidateDeductCustomerPointsRequest(req *contract.DeductCustomerPointsRequest) (error, string) + + // Customer Tokens + ValidateCreateCustomerTokensRequest(req *contract.CreateCustomerTokensRequest) (error, string) + ValidateUpdateCustomerTokensRequest(req *contract.UpdateCustomerTokensRequest) (error, string) + ValidateListCustomerTokensRequest(req *contract.ListCustomerTokensRequest) (error, string) + ValidateAddCustomerTokensRequest(req *contract.AddCustomerTokensRequest) (error, string) + ValidateDeductCustomerTokensRequest(req *contract.DeductCustomerTokensRequest) (error, string) + // Tiers ValidateCreateTierRequest(req *contract.CreateTierRequest) (error, string) ValidateUpdateTierRequest(req *contract.UpdateTierRequest) (error, string) @@ -46,6 +60,152 @@ func NewGamificationValidator() *GamificationValidatorImpl { } } +// Customer Points Validators +func (v *GamificationValidatorImpl) ValidateCreateCustomerPointsRequest(req *contract.CreateCustomerPointsRequest) (error, string) { + if err := v.validate.Struct(req); err != nil { + return err, "VALIDATION_ERROR" + } + + if req.Balance < 0 { + return errors.New("balance cannot be negative"), "INVALID_BALANCE" + } + + return nil, "" +} + +func (v *GamificationValidatorImpl) ValidateUpdateCustomerPointsRequest(req *contract.UpdateCustomerPointsRequest) (error, string) { + if err := v.validate.Struct(req); err != nil { + return err, "VALIDATION_ERROR" + } + + if req.Balance < 0 { + return errors.New("balance cannot be negative"), "INVALID_BALANCE" + } + + return nil, "" +} + +func (v *GamificationValidatorImpl) ValidateListCustomerPointsRequest(req *contract.ListCustomerPointsRequest) (error, string) { + if err := v.validate.Struct(req); err != nil { + return err, "VALIDATION_ERROR" + } + + if req.Page <= 0 { + req.Page = 1 + } + if req.Limit <= 0 { + req.Limit = 10 + } + if req.Limit > 100 { + req.Limit = 100 + } + + return nil, "" +} + +func (v *GamificationValidatorImpl) ValidateAddCustomerPointsRequest(req *contract.AddCustomerPointsRequest) (error, string) { + if err := v.validate.Struct(req); err != nil { + return err, "VALIDATION_ERROR" + } + + if req.Points <= 0 { + return errors.New("points must be greater than 0"), "INVALID_POINTS" + } + + return nil, "" +} + +func (v *GamificationValidatorImpl) ValidateDeductCustomerPointsRequest(req *contract.DeductCustomerPointsRequest) (error, string) { + if err := v.validate.Struct(req); err != nil { + return err, "VALIDATION_ERROR" + } + + if req.Points <= 0 { + return errors.New("points must be greater than 0"), "INVALID_POINTS" + } + + return nil, "" +} + +// Customer Tokens Validators +func (v *GamificationValidatorImpl) ValidateCreateCustomerTokensRequest(req *contract.CreateCustomerTokensRequest) (error, string) { + if err := v.validate.Struct(req); err != nil { + return err, "VALIDATION_ERROR" + } + + if req.Balance < 0 { + return errors.New("balance cannot be negative"), "INVALID_BALANCE" + } + + validTokenTypes := []string{"SPIN", "RAFFLE", "MINIGAME"} + if !contains(validTokenTypes, req.TokenType) { + return errors.New("invalid token type"), "INVALID_TOKEN_TYPE" + } + + return nil, "" +} + +func (v *GamificationValidatorImpl) ValidateUpdateCustomerTokensRequest(req *contract.UpdateCustomerTokensRequest) (error, string) { + if err := v.validate.Struct(req); err != nil { + return err, "VALIDATION_ERROR" + } + + if req.Balance < 0 { + return errors.New("balance cannot be negative"), "INVALID_BALANCE" + } + + return nil, "" +} + +func (v *GamificationValidatorImpl) ValidateListCustomerTokensRequest(req *contract.ListCustomerTokensRequest) (error, string) { + if err := v.validate.Struct(req); err != nil { + return err, "VALIDATION_ERROR" + } + + if req.Page <= 0 { + req.Page = 1 + } + if req.Limit <= 0 { + req.Limit = 10 + } + if req.Limit > 100 { + req.Limit = 100 + } + + if req.TokenType != "" { + validTokenTypes := []string{"SPIN", "RAFFLE", "MINIGAME"} + if !contains(validTokenTypes, req.TokenType) { + return errors.New("invalid token type"), "INVALID_TOKEN_TYPE" + } + } + + return nil, "" +} + +func (v *GamificationValidatorImpl) ValidateAddCustomerTokensRequest(req *contract.AddCustomerTokensRequest) (error, string) { + if err := v.validate.Struct(req); err != nil { + return err, "VALIDATION_ERROR" + } + + if req.Tokens <= 0 { + return errors.New("tokens must be greater than 0"), "INVALID_TOKENS" + } + + return nil, "" +} + +func (v *GamificationValidatorImpl) ValidateDeductCustomerTokensRequest(req *contract.DeductCustomerTokensRequest) (error, string) { + if err := v.validate.Struct(req); err != nil { + return err, "VALIDATION_ERROR" + } + + if req.Tokens <= 0 { + return errors.New("tokens must be greater than 0"), "INVALID_TOKENS" + } + + return nil, "" +} + // Tier Validators func (v *GamificationValidatorImpl) ValidateCreateTierRequest(req *contract.CreateTierRequest) (error, string) { if err := v.validate.Struct(req); err != nil { @@ -276,8 +436,8 @@ func (v *GamificationValidatorImpl) ValidateCreateGamePlayRequest(req *contract. return err, "VALIDATION_ERROR" } - if req.CoinsUsed < 0 { - return errors.New("coins used cannot be negative"), "INVALID_COINS_USED" + if req.TokenUsed < 0 { + return errors.New("token used cannot be negative"), "INVALID_TOKEN_USED" } return nil, "" @@ -306,6 +466,10 @@ func (v *GamificationValidatorImpl) ValidatePlayGameRequest(req *contract.PlayGa return err, "VALIDATION_ERROR" } + if req.TokenUsed < 0 { + return errors.New("token used cannot be negative"), "INVALID_TOKEN_USED" + } + return nil, "" } diff --git a/internal/validator/payment_method_validator.go b/internal/validator/payment_method_validator.go index 62d70e3..8173abf 100644 --- a/internal/validator/payment_method_validator.go +++ b/internal/validator/payment_method_validator.go @@ -95,7 +95,6 @@ func (v *PaymentMethodValidatorImpl) isValidPaymentMethodType(paymentMethodType string(constants.PaymentMethodTypeDigitalWallet), string(constants.PaymentMethodTypeQR), string(constants.PaymentMethodTypeEDC), - string(constants.PaymentMethodTypePoint), } for _, validType := range validTypes { diff --git a/internal/validator/wallet_validator.go b/internal/validator/wallet_validator.go deleted file mode 100644 index 862d137..0000000 --- a/internal/validator/wallet_validator.go +++ /dev/null @@ -1,66 +0,0 @@ -package validator - -import ( - "errors" - "strings" - "unicode/utf8" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" -) - -// walletAdjustmentReasonLimit is the size of wallet_transactions.reason. -const walletAdjustmentReasonLimit = 255 - -// walletCashOutWords are reasons that describe turning balance into money. EnakPoint -// and EnakCoin can never be cashed out, and an adjustment must not be used to record -// that it happened (docs/prd-point-coin.md K7). Words like "tunai" are left out on -// purpose: "paid in cash but the points never arrived" is a legitimate reason. -var walletCashOutWords = []string{"pencairan", "cairkan", "cash out", "cashout", "withdraw"} - -type WalletValidator interface { - ValidateAdjustRequest(req *contract.AdjustCustomerWalletRequest) (error, string) -} - -type WalletValidatorImpl struct{} - -func NewWalletValidator() *WalletValidatorImpl { - return &WalletValidatorImpl{} -} - -func (v *WalletValidatorImpl) ValidateAdjustRequest(req *contract.AdjustCustomerWalletRequest) (error, string) { - if req == nil { - return errors.New("request body is required"), constants.MissingFieldErrorCode - } - - req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency)) - if req.Currency == "" { - return errors.New("currency is required"), constants.MissingFieldErrorCode - } - if !constants.IsValidWalletCurrency(req.Currency) { - return errors.New("currency must be POINT or COIN"), constants.MalformedFieldErrorCode - } - - if req.Amount == 0 { - return errors.New("amount must not be zero: use a positive amount to add and a negative one to take away"), constants.ValidationErrorCode - } - - req.Reason = strings.TrimSpace(req.Reason) - if req.Reason == "" { - return errors.New("reason is required"), constants.MissingFieldErrorCode - } - if utf8.RuneCountInString(req.Reason) > walletAdjustmentReasonLimit { - return errors.New("reason must be at most 255 characters"), constants.MalformedFieldErrorCode - } - lower := strings.ToLower(req.Reason) - for _, word := range walletCashOutWords { - if strings.Contains(lower, word) { - return errors.New("EnakPoint and EnakCoin cannot be cashed out, so an adjustment cannot be for a cash-out"), constants.ValidationErrorCode - } - } - - if len(req.IdempotencyKey) > 50 { - return errors.New("idempotency_key must be at most 50 characters"), constants.MalformedFieldErrorCode - } - return nil, "" -} diff --git a/internal/validator/wallet_validator_test.go b/internal/validator/wallet_validator_test.go deleted file mode 100644 index b765d87..0000000 --- a/internal/validator/wallet_validator_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package validator - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" -) - -func TestWalletValidator_AdjustRequest(t *testing.T) { - v := NewWalletValidator() - - ok := &contract.AdjustCustomerWalletRequest{Currency: " point ", Amount: -25, Reason: " komplain #45 "} - err, _ := v.ValidateAdjustRequest(ok) - assert.NoError(t, err) - assert.Equal(t, constants.WalletCurrencyPoint, ok.Currency, "currency is normalised") - assert.Equal(t, "komplain #45", ok.Reason, "reason is trimmed") - - legit := &contract.AdjustCustomerWalletRequest{Currency: "COIN", Amount: 5, Reason: "Bayar tunai tapi poin tidak masuk"} - err, _ = v.ValidateAdjustRequest(legit) - assert.NoError(t, err, "mentioning a cash payment is not a cash-out") - - for name, req := range map[string]*contract.AdjustCustomerWalletRequest{ - "nil": nil, - "no currency": {Amount: 5, Reason: "x"}, - "unknown currency": {Currency: "GOLD", Amount: 5, Reason: "x"}, - "zero amount": {Currency: "POINT", Amount: 0, Reason: "x"}, - "no reason": {Currency: "POINT", Amount: 5, Reason: " "}, - "reason too long": {Currency: "POINT", Amount: 5, Reason: strings.Repeat("a", 256)}, - "cash-out reason": {Currency: "POINT", Amount: -500, Reason: "Pencairan saldo ke rekening"}, - "cash-out, English": {Currency: "POINT", Amount: -500, Reason: "customer asked to cash out"}, - "long key": {Currency: "POINT", Amount: 5, Reason: "x", IdempotencyKey: strings.Repeat("k", 51)}, - } { - t.Run(name, func(t *testing.T) { - err, code := v.ValidateAdjustRequest(req) - assert.Error(t, err) - assert.NotEmpty(t, code) - }) - } -} diff --git a/migrations/000090_create_wallet_tables.down.sql b/migrations/000090_create_wallet_tables.down.sql deleted file mode 100644 index 2369658..0000000 --- a/migrations/000090_create_wallet_tables.down.sql +++ /dev/null @@ -1,4 +0,0 @@ -DROP TABLE IF EXISTS wallet_lot_allocations; -DROP TABLE IF EXISTS wallet_lots; -DROP TABLE IF EXISTS wallet_transactions; -DROP TABLE IF EXISTS customer_wallets; diff --git a/migrations/000090_create_wallet_tables.up.sql b/migrations/000090_create_wallet_tables.up.sql deleted file mode 100644 index 4dcee08..0000000 --- a/migrations/000090_create_wallet_tables.up.sql +++ /dev/null @@ -1,131 +0,0 @@ --- EnakPoint & EnakCoin wallet (docs/prd-point-coin.md §8). Replaces customer_points --- and customer_tokens; the old tables stay until their data is migrated (PC-105). --- --- Balances are only ever changed together with a ledger row, in one transaction, and --- every ledger row must name where the value came from or went to (K5). The CHECKs --- below enforce that at the database so a bug in the application cannot skip it. - --- One row per customer. Besides holding the balances, this row is the lock every --- wallet operation for the customer takes first (SELECT ... FOR UPDATE), so --- concurrent operations on the same customer queue up instead of spending twice. -CREATE TABLE customer_wallets ( - customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE RESTRICT, - organization_id UUID NOT NULL REFERENCES organizations(id), - point_balance BIGINT NOT NULL DEFAULT 0, - coin_balance BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - - CONSTRAINT chk_customer_wallets_point_balance CHECK (point_balance >= 0), - CONSTRAINT chk_customer_wallets_coin_balance CHECK (coin_balance >= 0) -); - --- The ledger. Append-only: rows are never updated or deleted, a correction is a new --- row (EARN_REVERSAL, PAYMENT_REFUND or ADJUSTMENT) pointing at the one it corrects. --- ON DELETE RESTRICT on customers means a customer with history can only be --- deactivated, not hard-deleted. -CREATE TABLE wallet_transactions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - organization_id UUID NOT NULL, - customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, - currency VARCHAR(10) NOT NULL, - type VARCHAR(30) NOT NULL, - -- Signed: positive credits the wallet, negative debits it. - amount BIGINT NOT NULL, - balance_after BIGINT NOT NULL, - -- Ties the two rows of an exchange or a transfer together. - group_id UUID, - - -- Where the value came from (amount > 0) or went to (amount < 0). Required for - -- every type; §8.1 lists which reference_type each type uses. - reference_type VARCHAR(30) NOT NULL, - reference_id UUID NOT NULL, - - counterparty_customer_id UUID REFERENCES customers(id), - reverses_transaction_id UUID REFERENCES wallet_transactions(id), - outlet_id UUID, - -- The admin for ADJUSTMENT, the cashier for PAYMENT / PAYMENT_REFUND via POS. - created_by_user UUID, - reason VARCHAR(255), - - -- Display text frozen at creation, so a later rename of an outlet or a customer - -- does not rewrite history (same idea as the price snapshot on order_items). - description VARCHAR(255) NOT NULL, - -- Snapshot of whatever was used to compute the row: settings, point value, - -- exchange rate, reversal shortfall. - metadata JSONB DEFAULT '{}', - idempotency_key VARCHAR(100) UNIQUE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - - CONSTRAINT chk_wallet_transactions_currency CHECK (currency IN ('POINT', 'COIN')), - CONSTRAINT chk_wallet_transactions_amount CHECK (amount <> 0), - - -- Only EnakPoint can pay (K2); spending on games and exchanging out are EnakCoin only. - CONSTRAINT chk_wallet_transactions_point_only_types CHECK ( - type NOT IN ('PAYMENT', 'PAYMENT_REFUND', 'EXCHANGE_IN', 'REWARD_REDEEM') - OR currency = 'POINT'), - CONSTRAINT chk_wallet_transactions_coin_only_types CHECK ( - type NOT IN ('EXCHANGE_OUT', 'GAME_SPEND') OR currency = 'COIN'), - - CONSTRAINT chk_wallet_transactions_transfer_counterparty CHECK ( - type NOT IN ('TRANSFER_IN', 'TRANSFER_OUT') OR counterparty_customer_id IS NOT NULL), - CONSTRAINT chk_wallet_transactions_reversal_source CHECK ( - type NOT IN ('EARN_REVERSAL', 'PAYMENT_REFUND') OR reverses_transaction_id IS NOT NULL), - CONSTRAINT chk_wallet_transactions_adjustment_actor CHECK ( - type <> 'ADJUSTMENT' OR (created_by_user IS NOT NULL AND reason IS NOT NULL)), - CONSTRAINT chk_wallet_transactions_expire_lot CHECK ( - type <> 'EXPIRE' OR reference_type = 'LOT') -); - -CREATE INDEX idx_wallet_transactions_customer_id_created_at ON wallet_transactions(customer_id, created_at DESC); -CREATE INDEX idx_wallet_transactions_reference ON wallet_transactions(reference_type, reference_id); -CREATE INDEX idx_wallet_transactions_group_id ON wallet_transactions(group_id); -CREATE INDEX idx_wallet_transactions_counterparty_customer_id ON wallet_transactions(counterparty_customer_id); -CREATE INDEX idx_wallet_transactions_reverses_transaction_id ON wallet_transactions(reverses_transaction_id); - --- Balance kept per lot (K9). Every credit creates one or more lots with their own --- expiry, and every debit draws from the lots that expire soonest. A transfer, --- exchange or refund carries the expiry of the lot it came from and points back at it --- through origin_lot_id, so each unit can be traced to the EARN, ADJUSTMENT or --- MIGRATION that first created it. -CREATE TABLE wallet_lots ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - organization_id UUID NOT NULL, - customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, - currency VARCHAR(10) NOT NULL, - -- The incoming ledger row that created this lot. - source_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), - origin_lot_id UUID REFERENCES wallet_lots(id), - original_amount BIGINT NOT NULL, - -- The only column in the wallet tables that is ever updated. It is a cached - -- original_amount - SUM(wallet_lot_allocations.amount), kept for fast spending, - -- and the reconciliation job (§7.5) checks it against the allocations. - remaining_amount BIGINT NOT NULL, - -- NULL means the lot never expires. - expires_at TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - - CONSTRAINT chk_wallet_lots_currency CHECK (currency IN ('POINT', 'COIN')), - CONSTRAINT chk_wallet_lots_original_amount CHECK (original_amount > 0), - CONSTRAINT chk_wallet_lots_remaining_amount CHECK ( - remaining_amount >= 0 AND remaining_amount <= original_amount) -); - --- Spending order (K9): soonest expiry first, lots without an expiry last. -CREATE INDEX idx_wallet_lots_consume ON wallet_lots(customer_id, currency, expires_at NULLS LAST, created_at) - WHERE remaining_amount > 0; -CREATE INDEX idx_wallet_lots_expiry ON wallet_lots(expires_at) WHERE remaining_amount > 0; - --- Which lots each outgoing ledger row drew from, and how much from each. -CREATE TABLE wallet_lot_allocations ( - transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), - lot_id UUID NOT NULL REFERENCES wallet_lots(id), - amount BIGINT NOT NULL, - - PRIMARY KEY (transaction_id, lot_id), - CONSTRAINT chk_wallet_lot_allocations_amount CHECK (amount > 0) -); - --- Not in §8: the primary key cannot serve lookups by lot, which the reconciliation --- job needs to sum each lot's allocations. -CREATE INDEX idx_wallet_lot_allocations_lot_id ON wallet_lot_allocations(lot_id); diff --git a/migrations/000091_create_organization_settings.down.sql b/migrations/000091_create_organization_settings.down.sql deleted file mode 100644 index 62d6a91..0000000 --- a/migrations/000091_create_organization_settings.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS loyalty_setting_changes; -DROP TABLE IF EXISTS organization_settings; diff --git a/migrations/000091_create_organization_settings.up.sql b/migrations/000091_create_organization_settings.up.sql deleted file mode 100644 index 06e9ed1..0000000 --- a/migrations/000091_create_organization_settings.up.sql +++ /dev/null @@ -1,35 +0,0 @@ --- Settings that must be the same in every outlet of an organization, starting with the --- loyalty ones (docs/prd-point-coin.md F2, F12): point value, exchange rate, transfer --- limits and expiry. Same key-value shape as outlet_settings. -CREATE TABLE organization_settings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, - key VARCHAR(255) NOT NULL, - value TEXT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - UNIQUE(organization_id, key) -); - --- The unique constraint leads with organization_id, so it also serves the plain --- per-organization lookups and there is no separate index on that column. - --- Who changed which loyalty setting, from what, to what (F2). Covers both the --- organization settings above and the per-outlet loyalty keys in outlet_settings. --- Append-only. Values are stored as text, the same as in the settings tables. -CREATE TABLE loyalty_setting_changes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - organization_id UUID NOT NULL, - -- NULL for an organization setting. - outlet_id UUID, - key VARCHAR(100) NOT NULL, - -- NULL when the key had no stored value yet (it was on its default). - old_value TEXT, - new_value TEXT, - changed_by UUID NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - -CREATE INDEX idx_loyalty_setting_changes_organization_id_created_at ON loyalty_setting_changes(organization_id, created_at DESC); -CREATE INDEX idx_loyalty_setting_changes_outlet_id_created_at ON loyalty_setting_changes(outlet_id, created_at DESC) - WHERE outlet_id IS NOT NULL; diff --git a/migrations/000092_rename_campaign_tokens_to_coins.down.sql b/migrations/000092_rename_campaign_tokens_to_coins.down.sql deleted file mode 100644 index 06a8ab1..0000000 --- a/migrations/000092_rename_campaign_tokens_to_coins.down.sql +++ /dev/null @@ -1,5 +0,0 @@ -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'; diff --git a/migrations/000092_rename_campaign_tokens_to_coins.up.sql b/migrations/000092_rename_campaign_tokens_to_coins.up.sql deleted file mode 100644 index 8ccf3fc..0000000 --- a/migrations/000092_rename_campaign_tokens_to_coins.up.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 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'; diff --git a/migrations/000093_add_customer_pin.down.sql b/migrations/000093_add_customer_pin.down.sql deleted file mode 100644 index 95edae3..0000000 --- a/migrations/000093_add_customer_pin.down.sql +++ /dev/null @@ -1,9 +0,0 @@ -DROP TABLE IF EXISTS customer_security_events; - -ALTER TABLE customers - DROP CONSTRAINT IF EXISTS chk_customers_pin_failed_attempts, - DROP COLUMN IF EXISTS transfer_blocked_until, - DROP COLUMN IF EXISTS pin_locked_until, - DROP COLUMN IF EXISTS pin_failed_attempts, - DROP COLUMN IF EXISTS pin_set_at, - DROP COLUMN IF EXISTS pin_hash; diff --git a/migrations/000093_add_customer_pin.up.sql b/migrations/000093_add_customer_pin.up.sql deleted file mode 100644 index 12e722b..0000000 --- a/migrations/000093_add_customer_pin.up.sql +++ /dev/null @@ -1,32 +0,0 @@ --- Customer PIN (docs/prd-point-coin.md F11, K8). A 6-digit PIN, separate from the --- login password, approves everything that moves EnakPoint or EnakCoin on the --- customer's request. Only its bcrypt hash is stored. -ALTER TABLE customers - ADD COLUMN pin_hash VARCHAR(255), - ADD COLUMN pin_set_at TIMESTAMP WITH TIME ZONE, - -- Kept in the database, not a cache, so it cannot be dodged by waiting for a cache - -- to expire or by hitting another server (Q17). - ADD COLUMN pin_failed_attempts INT NOT NULL DEFAULT 0, - ADD COLUMN pin_locked_until TIMESTAMP WITH TIME ZONE, - -- Outgoing transfers are held for 24 hours after a PIN reset (Q16). - ADD COLUMN transfer_blocked_until TIMESTAMP WITH TIME ZONE, - ADD CONSTRAINT chk_customers_pin_failed_attempts CHECK (pin_failed_attempts >= 0); - --- Security log of PIN events. Not a balance movement, so not in wallet_transactions. -CREATE TABLE customer_security_events ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, - -- PIN_SET, PIN_CHANGED, PIN_RESET, PIN_FAILED, PIN_LOCKED, PIN_REMOVED_BY_ADMIN - event VARCHAR(30) NOT NULL, - -- The admin, for PIN_REMOVED_BY_ADMIN. - actor_user UUID, - reason VARCHAR(255), - ip_address VARCHAR(45), - user_agent VARCHAR(255), - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - - CONSTRAINT chk_customer_security_events_admin CHECK ( - event <> 'PIN_REMOVED_BY_ADMIN' OR (actor_user IS NOT NULL AND reason IS NOT NULL)) -); - -CREATE INDEX idx_customer_security_events_customer_id_created_at ON customer_security_events(customer_id, created_at DESC); diff --git a/migrations/000094_add_point_payment_method.down.sql b/migrations/000094_add_point_payment_method.down.sql deleted file mode 100644 index dd86218..0000000 --- a/migrations/000094_add_point_payment_method.down.sql +++ /dev/null @@ -1,16 +0,0 @@ -ALTER TABLE payments - DROP CONSTRAINT IF EXISTS chk_payments_point_pair, - DROP COLUMN IF EXISTS point_value, - DROP COLUMN IF EXISTS points_used; - -DROP TRIGGER IF EXISTS trigger_create_point_payment_method ON organizations; -DROP FUNCTION IF EXISTS create_point_payment_method(); - --- Fails if an EnakPoint method has been used by a payment, which is the point: those --- payments would lose their method. -DELETE FROM payment_methods WHERE type = 'point'; -DROP INDEX IF EXISTS uq_payment_methods_point_per_organization; - -ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check; -ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check - CHECK (type IN ('cash', 'card', 'digital_wallet')); diff --git a/migrations/000094_add_point_payment_method.up.sql b/migrations/000094_add_point_payment_method.up.sql deleted file mode 100644 index e39899e..0000000 --- a/migrations/000094_add_point_payment_method.up.sql +++ /dev/null @@ -1,45 +0,0 @@ --- Paying with EnakPoint (docs/prd-point-coin.md F9, §8, §10.5). - --- A new payment method type. Every organization has exactly one method of it, made by --- the system, which cannot be deleted or change type. -ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check; -ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check - CHECK (type IN ('cash', 'card', 'digital_wallet', 'point')); - -CREATE UNIQUE INDEX uq_payment_methods_point_per_organization ON payment_methods(organization_id) - WHERE type = 'point'; - -INSERT INTO payment_methods (organization_id, name, type, is_active) -SELECT id, 'EnakPoint', 'point', TRUE FROM organizations -ON CONFLICT (organization_id) WHERE type = 'point' DO NOTHING; - --- New organizations get theirs the same way they get their walk-in customer, whatever --- code path creates them. -CREATE OR REPLACE FUNCTION create_point_payment_method() -RETURNS TRIGGER AS $$ -BEGIN - INSERT INTO payment_methods (organization_id, name, type, is_active) - VALUES (NEW.id, 'EnakPoint', 'point', TRUE) - ON CONFLICT (organization_id) WHERE type = 'point' DO NOTHING; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trigger_create_point_payment_method - AFTER INSERT ON organizations - FOR EACH ROW - EXECUTE FUNCTION create_point_payment_method(); - --- A payment made with EnakPoint records how many were used and the rupiah value of one --- at that moment. The value is frozen so a refund returns exactly the EnakPoint used, --- whatever the value is by then. --- --- Written so it never evaluates to NULL: the form in the PRD, (both NULL) OR (both --- > 0), is NULL for points_used = 1000 with point_value NULL, and a CHECK only rejects --- FALSE, so a payment could lose its frozen value. -ALTER TABLE payments - ADD COLUMN points_used BIGINT, - ADD COLUMN point_value DECIMAL(10,2), - ADD CONSTRAINT chk_payments_point_pair CHECK ( - (points_used IS NULL) = (point_value IS NULL) - AND (points_used IS NULL OR (points_used > 0 AND point_value > 0))); diff --git a/migrations/000095_rename_game_plays_token_used_to_coins_used.down.sql b/migrations/000095_rename_game_plays_token_used_to_coins_used.down.sql deleted file mode 100644 index fb9fb88..0000000 --- a/migrations/000095_rename_game_plays_token_used_to_coins_used.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE game_plays RENAME CONSTRAINT chk_game_plays_coins_used_non_negative TO chk_game_plays_token_used_non_negative; -ALTER TABLE game_plays RENAME COLUMN coins_used TO token_used; diff --git a/migrations/000095_rename_game_plays_token_used_to_coins_used.up.sql b/migrations/000095_rename_game_plays_token_used_to_coins_used.up.sql deleted file mode 100644 index d59c7e1..0000000 --- a/migrations/000095_rename_game_plays_token_used_to_coins_used.up.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Every game now costs EnakCoin (docs/prd-point-coin.md F8, K1), so what a play used --- is a number of EnakCoin. -ALTER TABLE game_plays RENAME COLUMN token_used TO coins_used; -ALTER TABLE game_plays RENAME CONSTRAINT chk_game_plays_token_used_non_negative TO chk_game_plays_coins_used_non_negative; diff --git a/migrations/000096_create_customer_devices.down.sql b/migrations/000096_create_customer_devices.down.sql deleted file mode 100644 index b541b49..0000000 --- a/migrations/000096_create_customer_devices.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS customer_devices; diff --git a/migrations/000096_create_customer_devices.up.sql b/migrations/000096_create_customer_devices.up.sql deleted file mode 100644 index c8f9d0d..0000000 --- a/migrations/000096_create_customer_devices.up.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Devices of the customer app, so customers can get push notifications through FCM --- (docs/prd-point-coin.md F5: the recipient of a transfer is notified). user_devices --- only holds staff devices. -CREATE TABLE customer_devices ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, - device_id VARCHAR(255) NOT NULL, - platform VARCHAR(50) CHECK (platform IN ('android', 'ios', 'web')), - fcm_token VARCHAR(512) NOT NULL, - app_version VARCHAR(50), - last_active_at TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - -CREATE UNIQUE INDEX idx_customer_devices_customer_device ON customer_devices(customer_id, device_id); -CREATE INDEX idx_customer_devices_fcm_token ON customer_devices(fcm_token); diff --git a/migrations/000097_create_wallet_expiry_reminders.down.sql b/migrations/000097_create_wallet_expiry_reminders.down.sql deleted file mode 100644 index a154829..0000000 --- a/migrations/000097_create_wallet_expiry_reminders.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS wallet_expiry_reminders; diff --git a/migrations/000097_create_wallet_expiry_reminders.up.sql b/migrations/000097_create_wallet_expiry_reminders.up.sql deleted file mode 100644 index 74e500e..0000000 --- a/migrations/000097_create_wallet_expiry_reminders.up.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Which expiry reminders have gone out (docs/prd-point-coin.md F12): one per --- customer, currency and expiry day. The row is written before the push is sent, so --- several instances of the job, or a restart, never remind twice. -CREATE TABLE wallet_expiry_reminders ( - customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE, - currency VARCHAR(10) NOT NULL CHECK (currency IN ('POINT','COIN')), - expiry_date DATE NOT NULL, - amount BIGINT NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - PRIMARY KEY (customer_id, currency, expiry_date) -); -- 2.54.0