diff --git a/go.mod b/go.mod
index 5db9d96..45702a0 100644
--- a/go.mod
+++ b/go.mod
@@ -85,6 +85,7 @@ require (
github.com/xuri/excelize/v2 v2.9.0
go.uber.org/zap v1.21.0
golang.org/x/crypto v0.28.0
+ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6
golang.org/x/net v0.30.0
gorm.io/driver/postgres v1.5.0
gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11
diff --git a/go.sum b/go.sum
index fe00bc6..54243be 100644
--- a/go.sum
+++ b/go.sum
@@ -343,6 +343,7 @@ golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
+golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 h1:QE6XYQK6naiK1EPAe1g/ILLxN5RBoH5xkJk3CqlMI/Y=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
diff --git a/internal/common/errors/errors.go b/internal/common/errors/errors.go
index f3dba47..2d912a3 100644
--- a/internal/common/errors/errors.go
+++ b/internal/common/errors/errors.go
@@ -5,23 +5,24 @@ import "net/http"
type ErrType string
const (
- errRequestTimeOut ErrType = "Request Timeout to 3rd Party"
- errConnectTimeOut ErrType = "Connect Timeout to 3rd Party"
- errFailedExternalCall ErrType = "Failed response from 3rd Party call"
- errExternalCall ErrType = "error on 3rd Party call"
- errInvalidRequest ErrType = "Invalid Request"
- errBadRequest ErrType = "Bad Request"
- errOrderNotFound ErrType = "Astria order is not found"
- errCheckoutIDNotDefined ErrType = "Checkout client id not found"
- errInternalServer ErrType = "Internal Server error"
- errExternalServer ErrType = "External Server error"
- errUserIsNotFound ErrType = "User is not found"
- errInvalidLogin ErrType = "User email or password is invalid"
- errUnauthorized ErrType = "Unauthorized"
- errInsufficientBalance ErrType = "Insufficient Balance"
- errInactivePartner ErrType = "Partner's license is invalid or has expired. Please contact Admin Support."
- errTicketAlreadyUsed ErrType = "Ticket Already Used."
- errProductIsRequired ErrType = "Product"
+ errRequestTimeOut ErrType = "Request Timeout to 3rd Party"
+ errConnectTimeOut ErrType = "Connect Timeout to 3rd Party"
+ errFailedExternalCall ErrType = "Failed response from 3rd Party call"
+ errExternalCall ErrType = "error on 3rd Party call"
+ errInvalidRequest ErrType = "Invalid Request"
+ errBadRequest ErrType = "Bad Request"
+ errOrderNotFound ErrType = "Astria order is not found"
+ errCheckoutIDNotDefined ErrType = "Checkout client id not found"
+ errInternalServer ErrType = "Internal Server error"
+ errExternalServer ErrType = "External Server error"
+ errUserIsNotFound ErrType = "User is not found"
+ errInvalidLogin ErrType = "User email or password is invalid"
+ errUnauthorized ErrType = "Unauthorized"
+ errInsufficientBalance ErrType = "Insufficient Balance"
+ errInactivePartner ErrType = "Partner's license is invalid or has expired. Please contact Admin Support."
+ errTicketAlreadyUsed ErrType = "Ticket Already Used."
+ errProductIsRequired ErrType = "Product"
+ errEmailAndPhoneNumberRequired ErrType = "Email or Phone is required"
)
var (
@@ -41,6 +42,7 @@ var (
ErrorInsufficientBalance = NewServiceException(errInsufficientBalance)
ErrorInvalidLicense = NewServiceException(errInactivePartner)
ErrorTicketInvalidOrAlreadyUsed = NewServiceException(errTicketAlreadyUsed)
+ ErrorPhoneNumberEmailIsRequired = NewServiceException(errEmailAndPhoneNumberRequired)
)
type Error interface {
diff --git a/internal/constants/constants.go b/internal/constants/constants.go
index 0e543ef..05f7a41 100644
--- a/internal/constants/constants.go
+++ b/internal/constants/constants.go
@@ -52,3 +52,15 @@ func GenerateRefID() string {
var TimeNow = func() time.Time {
return time.Now()
}
+
+type RegistrationStatus string
+
+const (
+ RegistrationSuccess RegistrationStatus = "SUCCESS"
+ RegistrationPending RegistrationStatus = "PENDING"
+ RegistrationFailed RegistrationStatus = "FAILED"
+)
+
+func (u RegistrationStatus) String() string {
+ return string(u)
+}
diff --git a/internal/entity/auth.go b/internal/entity/auth.go
index 69e0e94..047a3c2 100644
--- a/internal/entity/auth.go
+++ b/internal/entity/auth.go
@@ -189,6 +189,7 @@ func (o *UserDB) SetDeleted(updatedby int64) {
o.Status = userstatus.Inactive
}
+type MemberList []*Customer
type CustomerList []*UserDB
type CustomerSearch struct {
@@ -210,3 +211,9 @@ func (b *CustomerList) ToCustomerList() []*Customer {
}
return users
}
+
+type MemberSearch struct {
+ Search string
+ Limit int
+ Offset int
+}
diff --git a/internal/entity/cust.go b/internal/entity/cust.go
index 680f43b..03db4a3 100644
--- a/internal/entity/cust.go
+++ b/internal/entity/cust.go
@@ -1,8 +1,17 @@
package entity
+import "time"
+
type CustomerResolutionRequest struct {
ID *int64
Name string
Email string
PhoneNumber string
+ BirthDate time.Time
+}
+
+type CustomerCheckResponse struct {
+ Exists bool
+ Customer *Customer
+ Message string
}
diff --git a/internal/entity/member.go b/internal/entity/member.go
new file mode 100644
index 0000000..ca1113b
--- /dev/null
+++ b/internal/entity/member.go
@@ -0,0 +1,70 @@
+package entity
+
+import (
+ "enaklo-pos-be/internal/constants"
+ "time"
+)
+
+type MemberRegistrationRequest struct {
+ Name string `json:"name" validate:"required"`
+ Email string `json:"email" validate:"required,email"`
+ Phone string `json:"phone" validate:"required"`
+ BirthDate time.Time `json:"birth_date"`
+ BranchID int64 `json:"branch_id" validate:"required"`
+ CashierID int64 `json:"cashier_id" validate:"required"`
+}
+
+type MemberRegistrationResponse struct {
+ Token string `json:"token"`
+ Status string `json:"status"`
+ ExpiresAt time.Time `json:"expires_at"`
+ Message string `json:"message"`
+}
+
+type MemberRegistration struct {
+ ID string `json:"id"`
+ Token string `json:"token"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Phone string `json:"phone"`
+ BirthDate time.Time `json:"birth_date"`
+ OTP string `json:"-"` // Not exposed in JSON responses
+ Status constants.RegistrationStatus `json:"status"`
+ ExpiresAt time.Time `json:"expires_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at,omitempty"`
+ BranchID int64 `json:"branch_id"`
+ CashierID int64 `json:"cashier_id"`
+}
+
+type MemberVerificationRequest struct {
+ Token string `json:"token" validate:"required"`
+ OTP string `json:"otp" validate:"required"`
+}
+
+type MemberVerificationResponse struct {
+ CustomerID int64 `json:"customer_id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Phone string `json:"phone"`
+ Points int `json:"points"`
+ Status string `json:"status"`
+}
+
+type MemberRegistrationStatus struct {
+ Token string `json:"token"`
+ Status string `json:"status"`
+ ExpiresAt time.Time `json:"expires_at"`
+ IsExpired bool `json:"is_expired"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type ResendOTPRequest struct {
+ Token string `json:"token" validate:"required"`
+}
+
+type ResendOTPResponse struct {
+ Token string `json:"token"`
+ ExpiresAt time.Time `json:"expires_at"`
+ Message string `json:"message"`
+}
diff --git a/internal/entity/order.go b/internal/entity/order.go
index 6b57f43..42aa965 100644
--- a/internal/entity/order.go
+++ b/internal/entity/order.go
@@ -75,7 +75,7 @@ type OrderItem struct {
ItemID int64 `gorm:"type:int;column:item_id"`
ItemType string `gorm:"type:varchar;column:item_type"`
Price float64 `gorm:"type:numeric;not null;column:price"`
- Quantity int `gorm:"type:int;column:qty"`
+ Quantity int `gorm:"type:int;column:quantity"`
CreatedAt time.Time `gorm:"autoCreateTime;column:created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at"`
CreatedBy int64 `gorm:"type:int;column:created_by"`
diff --git a/internal/entity/user.go b/internal/entity/user.go
index 9d4e425..19cf26a 100644
--- a/internal/entity/user.go
+++ b/internal/entity/user.go
@@ -49,6 +49,8 @@ type Customer struct {
SiteName string
PartnerName string
ResetPassword bool
+ CustomerID string
+ BirthDate time.Time
}
type AuthenticateUser struct {
diff --git a/internal/handlers/http/customer.go b/internal/handlers/http/customer.go
new file mode 100644
index 0000000..020bf76
--- /dev/null
+++ b/internal/handlers/http/customer.go
@@ -0,0 +1,81 @@
+package http
+
+import (
+ "enaklo-pos-be/internal/services/v2/customer"
+ "net/http"
+ "strconv"
+
+ "enaklo-pos-be/internal/common/errors"
+ "enaklo-pos-be/internal/entity"
+ "enaklo-pos-be/internal/handlers/request"
+ "enaklo-pos-be/internal/handlers/response"
+
+ "github.com/gin-gonic/gin"
+ "github.com/go-playground/validator/v10"
+)
+
+type CustomerHandler struct {
+ service customer.Service
+}
+
+func NewCustomerHandler(service customer.Service) *CustomerHandler {
+ return &CustomerHandler{
+ service: service,
+ }
+}
+
+func (h *CustomerHandler) Route(group *gin.RouterGroup, jwt gin.HandlerFunc) {
+ route := group.Group("/customers")
+
+ route.GET("/list", jwt, h.GetCustomerList)
+}
+
+func (h *CustomerHandler) GetCustomerList(c *gin.Context) {
+ ctx := request.GetMyContext(c)
+
+ searchQuery := c.DefaultQuery("search", "")
+ limitStr := c.DefaultQuery("limit", "10")
+ offsetStr := c.DefaultQuery("offset", "0")
+
+ // Convert limit and offset to integers
+ limit, err := strconv.Atoi(limitStr)
+ if err != nil {
+ response.ErrorWrapper(c, errors.ErrorBadRequest)
+ return
+ }
+
+ offset, err := strconv.Atoi(offsetStr)
+ if err != nil {
+ response.ErrorWrapper(c, errors.ErrorBadRequest)
+ return
+ }
+
+ req := &entity.MemberSearch{
+ Search: searchQuery,
+ Limit: limit,
+ Offset: offset,
+ }
+
+ validate := validator.New()
+ if err := validate.Struct(req); err != nil {
+ response.ErrorWrapper(c, err)
+ return
+ }
+
+ customerList, totalCount, err := h.service.GetAllCustomers(ctx, req)
+ if err != nil {
+ response.ErrorWrapper(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, response.BaseResponse{
+ Success: true,
+ Status: http.StatusOK,
+ Data: response.MapToCustomerListResponse(customerList),
+ PagingMeta: &response.PagingMeta{
+ Page: offset + 1,
+ Total: int64(totalCount),
+ Limit: limit,
+ },
+ })
+}
diff --git a/internal/handlers/http/member.go b/internal/handlers/http/member.go
new file mode 100644
index 0000000..c309dbb
--- /dev/null
+++ b/internal/handlers/http/member.go
@@ -0,0 +1,154 @@
+package http
+
+import (
+ "enaklo-pos-be/internal/common/errors"
+ "enaklo-pos-be/internal/entity"
+ "enaklo-pos-be/internal/handlers/request"
+ "enaklo-pos-be/internal/handlers/response"
+ "enaklo-pos-be/internal/services/member"
+ "github.com/gin-gonic/gin"
+ "github.com/go-playground/validator/v10"
+ "net/http"
+)
+
+type MemberHandler struct {
+ service member.RegistrationService
+}
+
+func NewMemberRegistrationHandler(service member.RegistrationService) *MemberHandler {
+ return &MemberHandler{
+ service: service,
+ }
+}
+
+func (h *MemberHandler) Route(group *gin.RouterGroup, jwt gin.HandlerFunc) {
+ route := group.Group("/member")
+
+ route.POST("/register", jwt, h.InitiateRegistration)
+ route.POST("/verify", jwt, h.VerifyOTP)
+ route.GET("/status", jwt, h.GetRegistrationStatus)
+ route.POST("/resend-otp", jwt, h.ResendOTP)
+ route.GET("/list", jwt, h.GetRegistrationStatus)
+}
+
+func (h *MemberHandler) InitiateRegistration(c *gin.Context) {
+ ctx := request.GetMyContext(c)
+ userID := ctx.RequestedBy()
+
+ var req request.InitiateRegistrationRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.ErrorWrapper(c, errors.ErrorBadRequest)
+ return
+ }
+
+ validate := validator.New()
+ if err := validate.Struct(req); err != nil {
+ response.ErrorWrapper(c, err)
+ return
+ }
+
+ birthDate, err := req.GetBirthdate()
+ if err != nil {
+ response.ErrorWrapper(c, err)
+ return
+ }
+
+ memberReq := &entity.MemberRegistrationRequest{
+ Name: req.Name,
+ Email: req.Email,
+ Phone: req.Phone,
+ BirthDate: birthDate,
+ BranchID: *ctx.GetPartnerID(),
+ CashierID: userID,
+ }
+
+ result, err := h.service.InitiateRegistration(ctx, memberReq)
+ if err != nil {
+ response.ErrorWrapper(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, response.BaseResponse{
+ Success: true,
+ Status: http.StatusOK,
+ Data: response.MapToMemberRegistrationResponse(result),
+ })
+}
+
+func (h *MemberHandler) VerifyOTP(c *gin.Context) {
+ ctx := request.GetMyContext(c)
+
+ var req request.VerifyOTPRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.ErrorWrapper(c, errors.ErrorBadRequest)
+ return
+ }
+
+ validate := validator.New()
+ if err := validate.Struct(req); err != nil {
+ response.ErrorWrapper(c, err)
+ return
+ }
+
+ result, err := h.service.VerifyOTP(ctx, req.Token, req.OTP)
+ if err != nil {
+ response.ErrorWrapper(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, response.BaseResponse{
+ Success: true,
+ Status: http.StatusOK,
+ Data: response.MapToMemberVerificationResponse(result),
+ })
+}
+
+func (h *MemberHandler) GetRegistrationStatus(c *gin.Context) {
+ ctx := request.GetMyContext(c)
+ token := c.Query("token")
+
+ if token == "" {
+ response.ErrorWrapper(c, errors.ErrorBadRequest)
+ return
+ }
+
+ result, err := h.service.GetRegistrationStatus(ctx, token)
+ if err != nil {
+ response.ErrorWrapper(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, response.BaseResponse{
+ Success: true,
+ Status: http.StatusOK,
+ Data: response.MapToMemberRegistrationStatus(result),
+ })
+}
+
+func (h *MemberHandler) ResendOTP(c *gin.Context) {
+ ctx := request.GetMyContext(c)
+
+ var req entity.ResendOTPRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.ErrorWrapper(c, errors.ErrorBadRequest)
+ return
+ }
+
+ validate := validator.New()
+ if err := validate.Struct(req); err != nil {
+ response.ErrorWrapper(c, err)
+ return
+ }
+
+ result, err := h.service.ResendOTP(ctx, req.Token)
+ if err != nil {
+ response.ErrorWrapper(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, response.BaseResponse{
+ Success: true,
+ Status: http.StatusOK,
+ Data: response.MapToResendOTPResponse(result),
+ })
+}
diff --git a/internal/handlers/request/member.go b/internal/handlers/request/member.go
new file mode 100644
index 0000000..9eca421
--- /dev/null
+++ b/internal/handlers/request/member.go
@@ -0,0 +1,34 @@
+package request
+
+import (
+ "time"
+)
+
+type InitiateRegistrationRequest struct {
+ Name string `json:"name" validate:"required"`
+ Email string `json:"email" validate:"required,email"`
+ Phone string `json:"phone" validate:"required"`
+ BirthDate string `json:"birth_date" validate:"required"`
+}
+
+func (i *InitiateRegistrationRequest) GetBirthdate() (time.Time, error) {
+ parsedDate, err := time.Parse("02-01-2006", i.BirthDate)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return parsedDate, nil
+}
+
+type VerifyOTPRequest struct {
+ Token string `json:"token" validate:"required"`
+ OTP string `json:"otp" validate:"required"`
+}
+
+type ResendOTPRequest struct {
+ Token string `json:"token" validate:"required"`
+}
+
+type CheckCustomerRequest struct {
+ Email string `json:"email"`
+ Phone string `json:"phone"`
+}
diff --git a/internal/handlers/response/customer.go b/internal/handlers/response/customer.go
new file mode 100644
index 0000000..f480a77
--- /dev/null
+++ b/internal/handlers/response/customer.go
@@ -0,0 +1,35 @@
+package response
+
+import (
+ "enaklo-pos-be/internal/entity"
+)
+
+func MapToCustomerResponse(customer *entity.Customer) CustomerResponse {
+ if customer == nil {
+ return CustomerResponse{}
+ }
+
+ return CustomerResponse{
+ ID: customer.ID,
+ Name: customer.Name,
+ Email: customer.Email,
+ Phone: customer.Phone,
+ Points: customer.Points,
+ CustomerID: customer.CustomerID,
+ CreatedAt: customer.CreatedAt.Format("2006-01-02"),
+ BirthDate: customer.BirthDate.Format("2006-01-02"),
+ }
+}
+
+func MapToCustomerListResponse(customers *entity.MemberList) []CustomerResponse {
+ if customers == nil {
+ return []CustomerResponse{}
+ }
+
+ responseList := []CustomerResponse{}
+ for _, customer := range *customers {
+ responseList = append(responseList, MapToCustomerResponse(customer))
+ }
+
+ return responseList
+}
diff --git a/internal/handlers/response/member.go b/internal/handlers/response/member.go
new file mode 100644
index 0000000..9053d46
--- /dev/null
+++ b/internal/handlers/response/member.go
@@ -0,0 +1,111 @@
+package response
+
+import (
+ "enaklo-pos-be/internal/entity"
+ "time"
+)
+
+type MemberRegistrationResponse struct {
+ Token string `json:"token"`
+ Status string `json:"status"`
+ ExpiresAt time.Time `json:"expires_at"`
+ Message string `json:"message"`
+}
+
+type MemberVerificationResponse struct {
+ CustomerID int64 `json:"customer_id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Phone string `json:"phone"`
+ Points int `json:"points"`
+ Status string `json:"status"`
+}
+
+type MemberRegistrationStatus struct {
+ Token string `json:"token"`
+ Status string `json:"status"`
+ ExpiresAt time.Time `json:"expires_at"`
+ IsExpired bool `json:"is_expired"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type ResendOTPResponse struct {
+ Token string `json:"token"`
+ ExpiresAt time.Time `json:"expires_at"`
+ Message string `json:"message"`
+}
+
+type CustomerCheckResponse struct {
+ Exists bool `json:"exists"`
+ Customer *CustomerResponse `json:"customer,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+type CustomerResponse struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Phone string `json:"phone"`
+ BirthDate string `json:"birth_date,omitempty"`
+ Points int `json:"points"`
+ CreatedAt string `json:"created_at"`
+ CustomerID string `json:"customer_id"`
+}
+
+func MapToMemberRegistrationResponse(entity *entity.MemberRegistrationResponse) MemberRegistrationResponse {
+ return MemberRegistrationResponse{
+ Token: entity.Token,
+ Status: entity.Status,
+ ExpiresAt: entity.ExpiresAt,
+ Message: entity.Message,
+ }
+}
+
+func MapToMemberVerificationResponse(entity *entity.MemberVerificationResponse) MemberVerificationResponse {
+ return MemberVerificationResponse{
+ CustomerID: entity.CustomerID,
+ Name: entity.Name,
+ Email: entity.Email,
+ Phone: entity.Phone,
+ Points: entity.Points,
+ Status: entity.Status,
+ }
+}
+
+func MapToMemberRegistrationStatus(entity *entity.MemberRegistrationStatus) MemberRegistrationStatus {
+ return MemberRegistrationStatus{
+ Token: entity.Token,
+ Status: entity.Status,
+ ExpiresAt: entity.ExpiresAt,
+ IsExpired: entity.IsExpired,
+ CreatedAt: entity.CreatedAt,
+ }
+}
+
+func MapToResendOTPResponse(entity *entity.ResendOTPResponse) ResendOTPResponse {
+ return ResendOTPResponse{
+ Token: entity.Token,
+ ExpiresAt: entity.ExpiresAt,
+ Message: entity.Message,
+ }
+}
+
+func MapToCustomerCheckResponse(entity *entity.CustomerCheckResponse) CustomerCheckResponse {
+ response := CustomerCheckResponse{
+ Exists: entity.Exists,
+ Message: entity.Message,
+ }
+
+ if entity.Customer != nil {
+ customer := &CustomerResponse{
+ ID: entity.Customer.ID,
+ Name: entity.Customer.Name,
+ Email: entity.Customer.Email,
+ Phone: entity.Customer.Phone,
+ CreatedAt: entity.Customer.CreatedAt.Format("2006-01-02"),
+ }
+ response.Customer = customer
+ }
+
+ return response
+}
diff --git a/internal/repository/customer_repo.go b/internal/repository/customer_repo.go
index 1d9bc53..3a069e1 100644
--- a/internal/repository/customer_repo.go
+++ b/internal/repository/customer_repo.go
@@ -15,6 +15,8 @@ type CustomerRepo interface {
FindByPhone(ctx mycontext.Context, phone string) (*entity.Customer, error)
FindByEmail(ctx mycontext.Context, email string) (*entity.Customer, error)
AddPoints(ctx mycontext.Context, id int64, points int) error
+ FindSequence(ctx mycontext.Context, partnerID int64) (int64, error)
+ GetAllCustomers(ctx mycontext.Context, req entity.MemberSearch) (entity.MemberList, int, error)
}
type customerRepository struct {
@@ -105,24 +107,128 @@ func (r *customerRepository) AddPoints(ctx mycontext.Context, id int64, points i
func (r *customerRepository) toCustomerDBModel(customer *entity.Customer) models.CustomerDB {
return models.CustomerDB{
- ID: customer.ID,
- Name: customer.Name,
- Email: customer.Email,
- Phone: customer.Phone,
- Points: customer.Points,
- CreatedAt: customer.CreatedAt,
- UpdatedAt: customer.UpdatedAt,
+ ID: customer.ID,
+ Name: customer.Name,
+ Email: customer.Email,
+ Phone: customer.Phone,
+ Points: customer.Points,
+ CreatedAt: customer.CreatedAt,
+ UpdatedAt: customer.UpdatedAt,
+ CustomerID: customer.CustomerID,
+ BirthDate: customer.BirthDate,
}
}
+func (r *customerRepository) FindSequence(ctx mycontext.Context, partnerID int64) (int64, error) {
+ tx := r.db.Begin()
+ if tx.Error != nil {
+ return 0, errors.Wrap(tx.Error, "failed to begin transaction")
+ }
+
+ defer func() {
+ if r := recover(); r != nil {
+ tx.Rollback()
+ }
+ }()
+
+ var sequence models.PartnerMemberSequence
+
+ result := tx.Where("partner_id = ?", partnerID).First(&sequence)
+ if result.Error != nil {
+ if errors.Is(result.Error, gorm.ErrRecordNotFound) {
+ now := time.Now()
+ newSequence := models.PartnerMemberSequence{
+ PartnerID: partnerID,
+ LastSequence: 1,
+ UpdatedAt: now,
+ }
+
+ if err := tx.Create(&newSequence).Error; err != nil {
+ tx.Rollback()
+ return 0, errors.Wrap(err, "failed to create new sequence")
+ }
+
+ if err := tx.Commit().Error; err != nil {
+ return 0, errors.Wrap(err, "failed to commit transaction")
+ }
+
+ return 1, nil
+ }
+
+ tx.Rollback()
+ return 0, errors.Wrap(result.Error, "failed to query sequence")
+ }
+
+ newSequenceValue := sequence.LastSequence + 1
+ updates := map[string]interface{}{
+ "last_sequence": newSequenceValue,
+ "updated_at": time.Now(),
+ }
+
+ if err := tx.Model(&sequence).Updates(updates).Error; err != nil {
+ tx.Rollback()
+ return 0, errors.Wrap(err, "failed to update sequence")
+ }
+
+ if err := tx.Commit().Error; err != nil {
+ return 0, errors.Wrap(err, "failed to commit transaction")
+ }
+
+ return newSequenceValue, nil
+}
+
func (r *customerRepository) toDomainCustomerModel(dbModel *models.CustomerDB) *entity.Customer {
return &entity.Customer{
- ID: dbModel.ID,
- Name: dbModel.Name,
- Email: dbModel.Email,
- Phone: dbModel.Phone,
- Points: dbModel.Points,
- CreatedAt: dbModel.CreatedAt,
- UpdatedAt: dbModel.UpdatedAt,
+ ID: dbModel.ID,
+ Name: dbModel.Name,
+ Email: dbModel.Email,
+ Phone: dbModel.Phone,
+ Points: dbModel.Points,
+ CreatedAt: dbModel.CreatedAt,
+ UpdatedAt: dbModel.UpdatedAt,
+ CustomerID: dbModel.CustomerID,
+ BirthDate: dbModel.BirthDate,
}
}
+
+func (r *customerRepository) GetAllCustomers(ctx mycontext.Context, req entity.MemberSearch) (entity.MemberList, int, error) {
+ if req.Limit <= 0 {
+ req.Limit = 10
+ }
+ if req.Offset < 0 {
+ req.Offset = 0
+ }
+
+ query := r.db.Model(&models.CustomerDB{})
+
+ if req.Search != "" {
+ searchTerm := "%" + req.Search + "%"
+ query = query.Where(
+ "name ILIKE ? OR email ILIKE ? OR phone ILIKE ?",
+ searchTerm, searchTerm, searchTerm,
+ )
+ }
+
+ var totalCount int64
+ if err := query.Count(&totalCount).Error; err != nil {
+ return nil, 0, errors.Wrap(err, "failed to count customers")
+ }
+
+ var customersDB []models.CustomerDB
+ result := query.
+ Order("created_at DESC").
+ Limit(req.Limit).
+ Offset(req.Offset).
+ Find(&customersDB)
+
+ if result.Error != nil {
+ return nil, 0, errors.Wrap(result.Error, "failed to retrieve customers")
+ }
+
+ customers := make(entity.MemberList, len(customersDB))
+ for i, customerDB := range customersDB {
+ customers[i] = r.toDomainCustomerModel(&customerDB)
+ }
+
+ return customers, int(totalCount), nil
+}
diff --git a/internal/repository/member_repo.go b/internal/repository/member_repo.go
new file mode 100644
index 0000000..c0de2c8
--- /dev/null
+++ b/internal/repository/member_repo.go
@@ -0,0 +1,138 @@
+package repository
+
+import (
+ "enaklo-pos-be/internal/common/logger"
+ "enaklo-pos-be/internal/common/mycontext"
+ "enaklo-pos-be/internal/constants"
+ "enaklo-pos-be/internal/entity"
+ "enaklo-pos-be/internal/repository/models"
+ "errors"
+ "time"
+
+ "go.uber.org/zap"
+ "gorm.io/gorm"
+)
+
+type MemberRepository interface {
+ CreateRegistration(ctx mycontext.Context, registration *entity.MemberRegistration) (*entity.MemberRegistration, error)
+ GetRegistrationByToken(ctx mycontext.Context, token string) (*entity.MemberRegistration, error)
+ UpdateRegistrationStatus(ctx mycontext.Context, token string, status constants.RegistrationStatus) error
+ UpdateRegistrationOTP(ctx mycontext.Context, token string, otp string, expiresAt time.Time) error
+}
+
+type memberRepository struct {
+ db *gorm.DB
+}
+
+func NewMemberRepository(db *gorm.DB) MemberRepository {
+ return &memberRepository{
+ db: db,
+ }
+}
+
+func (r *memberRepository) CreateRegistration(ctx mycontext.Context, registration *entity.MemberRegistration) (*entity.MemberRegistration, error) {
+ registrationDB := r.toRegistrationDBModel(registration)
+
+ if err := r.db.Create(®istrationDB).Error; err != nil {
+ logger.ContextLogger(ctx).Error("failed to create member registration", zap.Error(err))
+ return nil, errors.New("failed to insert member registration")
+ }
+
+ return registration, nil
+}
+
+func (r *memberRepository) GetRegistrationByToken(ctx mycontext.Context, token string) (*entity.MemberRegistration, error) {
+ var registrationDB models.MemberRegistrationDB
+
+ if err := r.db.Where("token = ?", token).First(®istrationDB).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("registration not found")
+ }
+ logger.ContextLogger(ctx).Error("failed to get registration by token", zap.Error(err))
+ return nil, errors.New("failed to get registration by token")
+ }
+
+ registration := r.toDomainRegistrationModel(®istrationDB)
+ return registration, nil
+}
+
+func (r *memberRepository) UpdateRegistrationStatus(ctx mycontext.Context, token string, status constants.RegistrationStatus) error {
+ now := time.Now()
+
+ result := r.db.Model(&models.MemberRegistrationDB{}).
+ Where("token = ?", token).
+ Updates(map[string]interface{}{
+ "status": status,
+ "updated_at": now,
+ })
+
+ if result.Error != nil {
+ logger.ContextLogger(ctx).Error("failed to update registration status", zap.Error(result.Error))
+ return errors.New("failed to update registration status")
+ }
+
+ if result.RowsAffected == 0 {
+ return errors.New("registration not found")
+ }
+
+ return nil
+}
+
+func (r *memberRepository) UpdateRegistrationOTP(ctx mycontext.Context, token string, otp string, expiresAt time.Time) error {
+ now := time.Now()
+
+ result := r.db.Model(&models.MemberRegistrationDB{}).
+ Where("token = ?", token).
+ Updates(map[string]interface{}{
+ "otp": otp,
+ "expires_at": expiresAt,
+ "updated_at": now,
+ })
+
+ if result.Error != nil {
+ logger.ContextLogger(ctx).Error("failed to update registration OTP", zap.Error(result.Error))
+ return errors.New("failed to update registration OTP")
+ }
+
+ if result.RowsAffected == 0 {
+ return errors.New("registration not found")
+ }
+
+ return nil
+}
+
+func (r *memberRepository) toRegistrationDBModel(registration *entity.MemberRegistration) models.MemberRegistrationDB {
+ return models.MemberRegistrationDB{
+ ID: registration.ID,
+ Token: registration.Token,
+ Name: registration.Name,
+ Email: registration.Email,
+ Phone: registration.Phone,
+ BirthDate: registration.BirthDate,
+ OTP: registration.OTP,
+ Status: registration.Status.String(),
+ ExpiresAt: registration.ExpiresAt,
+ CreatedAt: registration.CreatedAt,
+ UpdatedAt: registration.UpdatedAt,
+ BranchID: registration.BranchID,
+ CashierID: registration.CashierID,
+ }
+}
+
+func (r *memberRepository) toDomainRegistrationModel(dbModel *models.MemberRegistrationDB) *entity.MemberRegistration {
+ return &entity.MemberRegistration{
+ ID: dbModel.ID,
+ Token: dbModel.Token,
+ Name: dbModel.Name,
+ Email: dbModel.Email,
+ Phone: dbModel.Phone,
+ BirthDate: dbModel.BirthDate,
+ OTP: dbModel.OTP,
+ Status: constants.RegistrationStatus(dbModel.Status),
+ ExpiresAt: dbModel.ExpiresAt,
+ CreatedAt: dbModel.CreatedAt,
+ UpdatedAt: dbModel.UpdatedAt,
+ BranchID: dbModel.BranchID,
+ CashierID: dbModel.CashierID,
+ }
+}
diff --git a/internal/repository/models/customer.go b/internal/repository/models/customer.go
index 0ea80c4..ae43218 100644
--- a/internal/repository/models/customer.go
+++ b/internal/repository/models/customer.go
@@ -5,15 +5,28 @@ import (
)
type CustomerDB struct {
- ID int64 `gorm:"primaryKey;column:id"`
- Name string `gorm:"column:name"`
- Email string `gorm:"column:email"`
- Phone string `gorm:"column:phone"`
- Points int `gorm:"column:points"`
- CreatedAt time.Time `gorm:"column:created_at"`
- UpdatedAt time.Time `gorm:"column:updated_at"`
+ ID int64 `gorm:"primaryKey;column:id"`
+ Name string `gorm:"column:name"`
+ Email string `gorm:"column:email"`
+ Phone string `gorm:"column:phone"`
+ Points int `gorm:"column:points"`
+ CreatedAt time.Time `gorm:"column:created_at"`
+ UpdatedAt time.Time `gorm:"column:updated_at"`
+ CustomerID string `gorm:"column:customer_id"`
+ BirthDate time.Time `gorm:"column:birth_date"`
}
func (CustomerDB) TableName() string {
return "customers"
}
+
+type PartnerMemberSequence struct {
+ ID int64 `gorm:"column:id;primary_key;auto_increment"`
+ PartnerID int64 `gorm:"column:partner_id;not null;index:idx_partner_month,unique"`
+ LastSequence int64 `gorm:"column:last_sequence;not null;default:0"`
+ UpdatedAt time.Time `gorm:"column:updated_at;not null"`
+}
+
+func (PartnerMemberSequence) TableName() string {
+ return "partner_member_sequences"
+}
diff --git a/internal/repository/models/member.go b/internal/repository/models/member.go
new file mode 100644
index 0000000..053df3c
--- /dev/null
+++ b/internal/repository/models/member.go
@@ -0,0 +1,25 @@
+package models
+
+import (
+ "time"
+)
+
+type MemberRegistrationDB struct {
+ ID string `gorm:"column:id;primary_key"`
+ Token string `gorm:"column:token;unique_index"`
+ Name string `gorm:"column:name"`
+ Email string `gorm:"column:email"`
+ Phone string `gorm:"column:phone"`
+ BirthDate time.Time `gorm:"column:birth_date"`
+ OTP string `gorm:"column:otp"`
+ Status string `gorm:"column:status"`
+ ExpiresAt time.Time `gorm:"column:expires_at"`
+ CreatedAt time.Time `gorm:"column:created_at"`
+ UpdatedAt time.Time `gorm:"column:updated_at"`
+ BranchID int64 `gorm:"column:branch_id"`
+ CashierID int64 `gorm:"column:cashier_id"`
+}
+
+func (MemberRegistrationDB) TableName() string {
+ return "member_registrations"
+}
diff --git a/internal/repository/repository.go b/internal/repository/repository.go
index f8b387c..0144ed5 100644
--- a/internal/repository/repository.go
+++ b/internal/repository/repository.go
@@ -52,10 +52,11 @@ type RepoManagerImpl struct {
PG PaymentGateway
LinkQu LinkQu
- OrderRepo OrderRepository
- CustomerRepo CustomerRepo
- ProductRepo ProductRepository
- TransactionRepo TransactionRepo
+ OrderRepo OrderRepository
+ CustomerRepo CustomerRepo
+ ProductRepo ProductRepository
+ TransactionRepo TransactionRepo
+ MemberRepository MemberRepository
}
func NewRepoManagerImpl(db *gorm.DB, cfg *config.Config) *RepoManagerImpl {
@@ -80,10 +81,11 @@ func NewRepoManagerImpl(db *gorm.DB, cfg *config.Config) *RepoManagerImpl {
PG: pg.NewPaymentGatewayRepo(&cfg.Midtrans, &cfg.LinkQu),
LinkQu: linkqu.NewLinkQuService(&cfg.LinkQu),
- OrderRepo: NeworderRepository(db),
- CustomerRepo: NewCustomerRepository(db),
- ProductRepo: NewproductRepository(db),
- TransactionRepo: NewTransactionRepository(db),
+ OrderRepo: NeworderRepository(db),
+ CustomerRepo: NewCustomerRepository(db),
+ ProductRepo: NewproductRepository(db),
+ TransactionRepo: NewTransactionRepository(db),
+ MemberRepository: NewMemberRepository(db),
}
}
diff --git a/internal/routes/routes.go b/internal/routes/routes.go
index 6f1c269..69a3aef 100644
--- a/internal/routes/routes.go
+++ b/internal/routes/routes.go
@@ -78,6 +78,8 @@ func RegisterPrivateRoutesV2(app *app.Server, serviceManager *services.ServiceMa
serverRoutes := []HTTPHandlerRoutes{
http2.NewOrderHandler(serviceManager.OrderV2Svc),
+ http2.NewMemberRegistrationHandler(serviceManager.MemberRegistrationSvc),
+ http2.NewCustomerHandler(serviceManager.CustomerV2Svc),
}
for _, handler := range serverRoutes {
diff --git a/internal/services/member/member.go b/internal/services/member/member.go
new file mode 100644
index 0000000..375bda2
--- /dev/null
+++ b/internal/services/member/member.go
@@ -0,0 +1,51 @@
+package member
+
+import (
+ "context"
+ "enaklo-pos-be/internal/common/mycontext"
+ "enaklo-pos-be/internal/constants"
+ "enaklo-pos-be/internal/entity"
+ "time"
+)
+
+type RegistrationService interface {
+ InitiateRegistration(ctx mycontext.Context, request *entity.MemberRegistrationRequest) (*entity.MemberRegistrationResponse, error)
+ VerifyOTP(ctx mycontext.Context, token string, otp string) (*entity.MemberVerificationResponse, error)
+ GetRegistrationStatus(ctx mycontext.Context, token string) (*entity.MemberRegistrationStatus, error)
+ ResendOTP(ctx mycontext.Context, token string) (*entity.ResendOTPResponse, error)
+}
+
+type memberSvc struct {
+ repo Repository
+ notification NotificationService
+ customerSvc CustomerService
+}
+
+type Repository interface {
+ CreateRegistration(ctx mycontext.Context, registration *entity.MemberRegistration) (*entity.MemberRegistration, error)
+ GetRegistrationByToken(ctx mycontext.Context, token string) (*entity.MemberRegistration, error)
+ UpdateRegistrationStatus(ctx mycontext.Context, token string, status constants.RegistrationStatus) error
+ UpdateRegistrationOTP(ctx mycontext.Context, token string, otp string, expiresAt time.Time) error
+}
+
+type NotificationService interface {
+ SendEmailTransactional(ctx context.Context, param entity.SendEmailNotificationParam) error
+}
+
+type CustomerService interface {
+ ResolveCustomer(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (int64, error)
+ GetCustomer(ctx mycontext.Context, id int64) (*entity.Customer, error)
+ CustomerCheck(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (*entity.CustomerCheckResponse, error)
+}
+
+func NewMemberRegistrationService(
+ repo Repository,
+ notification NotificationService,
+ customerSvc CustomerService,
+) RegistrationService {
+ return &memberSvc{
+ repo: repo,
+ notification: notification,
+ customerSvc: customerSvc,
+ }
+}
diff --git a/internal/services/member/member_registration.go b/internal/services/member/member_registration.go
new file mode 100644
index 0000000..73f4054
--- /dev/null
+++ b/internal/services/member/member_registration.go
@@ -0,0 +1,262 @@
+package member
+
+import (
+ "enaklo-pos-be/internal/common/logger"
+ "enaklo-pos-be/internal/common/mycontext"
+ "enaklo-pos-be/internal/constants"
+ "enaklo-pos-be/internal/entity"
+ "errors"
+ "go.uber.org/zap"
+ "golang.org/x/exp/rand"
+ "time"
+)
+
+func (s *memberSvc) InitiateRegistration(
+ ctx mycontext.Context,
+ request *entity.MemberRegistrationRequest,
+) (*entity.MemberRegistrationResponse, error) {
+ customerResolution := &entity.CustomerResolutionRequest{
+ Email: request.Email,
+ PhoneNumber: request.Phone,
+ }
+
+ checkResult, err := s.customerSvc.CustomerCheck(ctx, customerResolution)
+ if checkResult.Exists {
+ return nil, errors.New(checkResult.Message)
+ }
+
+ otp := generateOTP(6)
+
+ token := constants.GenerateUUID()
+
+ registration := &entity.MemberRegistration{
+ ID: constants.GenerateUUID(),
+ Token: token,
+ Name: request.Name,
+ Email: request.Email,
+ Phone: request.Phone,
+ BirthDate: request.BirthDate,
+ OTP: otp,
+ Status: constants.RegistrationPending,
+ ExpiresAt: constants.TimeNow().Add(10 * time.Minute), // OTP expires in 10 minutes
+ CreatedAt: constants.TimeNow(),
+ UpdatedAt: constants.TimeNow(),
+ BranchID: request.BranchID,
+ CashierID: request.CashierID,
+ }
+
+ savedRegistration, err := s.repo.CreateRegistration(ctx, registration)
+ if err != nil {
+ logger.ContextLogger(ctx).Error("failed to create member registration", zap.Error(err))
+ return nil, err
+ }
+
+ err = s.sendRegistrationOTP(ctx, savedRegistration)
+ if err != nil {
+ logger.ContextLogger(ctx).Warn("failed to send OTP", zap.Error(err))
+ }
+
+ return &entity.MemberRegistrationResponse{
+ Token: token,
+ Status: savedRegistration.Status.String(),
+ ExpiresAt: savedRegistration.ExpiresAt,
+ Message: "Registration initiated. Please verify with OTP sent to your email.",
+ }, nil
+}
+
+func (s *memberSvc) VerifyOTP(
+ ctx mycontext.Context,
+ token string,
+ otp string,
+) (*entity.MemberVerificationResponse, error) {
+ logger.ContextLogger(ctx).Info("verifying OTP for member registration", zap.String("token", token))
+
+ registration, err := s.repo.GetRegistrationByToken(ctx, token)
+ if err != nil {
+ logger.ContextLogger(ctx).Error("failed to get registration", zap.Error(err))
+ return nil, errors.New("invalid registration token")
+ }
+
+ if registration.Status == constants.RegistrationSuccess {
+ return nil, errors.New("registration already completed")
+ }
+
+ if registration.ExpiresAt.Before(constants.TimeNow()) {
+ return nil, errors.New("registration expired")
+ }
+
+ if registration.OTP != otp {
+ return nil, errors.New("invalid OTP")
+ }
+
+ customerResolution := &entity.CustomerResolutionRequest{
+ Name: registration.Name,
+ Email: registration.Email,
+ PhoneNumber: registration.Phone,
+ BirthDate: registration.BirthDate,
+ }
+
+ customerID, err := s.customerSvc.ResolveCustomer(ctx, customerResolution)
+ if err != nil {
+ logger.ContextLogger(ctx).Error("failed to create customer", zap.Error(err))
+ return nil, errors.New("failed to create member record")
+ }
+
+ err = s.repo.UpdateRegistrationStatus(ctx, token, constants.RegistrationSuccess)
+ if err != nil {
+ logger.ContextLogger(ctx).Warn("failed to update registration status", zap.Error(err))
+ }
+
+ customer, err := s.customerSvc.GetCustomer(ctx, customerID)
+ if err != nil {
+ logger.ContextLogger(ctx).Warn("failed to get created customer", zap.Error(err))
+
+ return &entity.MemberVerificationResponse{
+ CustomerID: customerID,
+ Name: registration.Name,
+ Email: registration.Email,
+ Phone: registration.Phone,
+ Status: "Registration completed successfully",
+ }, nil
+ }
+
+ err = s.sendWelcomeEmail(ctx, customer)
+ if err != nil {
+ logger.ContextLogger(ctx).Warn("failed to send welcome email", zap.Error(err))
+ }
+
+ return &entity.MemberVerificationResponse{
+ CustomerID: customer.ID,
+ Name: customer.Name,
+ Email: customer.Email,
+ Phone: customer.Phone,
+ Points: customer.Points,
+ Status: "Registration completed successfully",
+ }, nil
+}
+
+func (s *memberSvc) GetRegistrationStatus(
+ ctx mycontext.Context,
+ token string,
+) (*entity.MemberRegistrationStatus, error) {
+ logger.ContextLogger(ctx).Info("checking registration status", zap.String("token", token))
+
+ registration, err := s.repo.GetRegistrationByToken(ctx, token)
+ if err != nil {
+ logger.ContextLogger(ctx).Error("failed to get registration", zap.Error(err))
+ return nil, errors.New("invalid registration token")
+ }
+
+ return &entity.MemberRegistrationStatus{
+ Token: registration.Token,
+ Status: registration.Status.String(),
+ ExpiresAt: registration.ExpiresAt,
+ IsExpired: registration.ExpiresAt.Before(constants.TimeNow()),
+ CreatedAt: registration.CreatedAt,
+ }, nil
+}
+
+func (s *memberSvc) ResendOTP(
+ ctx mycontext.Context,
+ token string,
+) (*entity.ResendOTPResponse, error) {
+ logger.ContextLogger(ctx).Info("resending OTP", zap.String("token", token))
+
+ // Get registration by token
+ registration, err := s.repo.GetRegistrationByToken(ctx, token)
+ if err != nil {
+ logger.ContextLogger(ctx).Error("failed to get registration", zap.Error(err))
+ return nil, errors.New("invalid registration token")
+ }
+
+ if registration.Status == constants.RegistrationSuccess {
+ return nil, errors.New("registration already completed")
+ }
+
+ newOTP := generateOTP(6)
+ newExpiresAt := constants.TimeNow().Add(10 * time.Minute)
+
+ err = s.repo.UpdateRegistrationOTP(ctx, token, newOTP, newExpiresAt)
+ if err != nil {
+ logger.ContextLogger(ctx).Error("failed to update OTP", zap.Error(err))
+ return nil, errors.New("failed to generate new OTP")
+ }
+
+ registration.OTP = newOTP
+ registration.ExpiresAt = newExpiresAt
+
+ err = s.sendRegistrationOTP(ctx, registration)
+ if err != nil {
+ logger.ContextLogger(ctx).Warn("failed to send OTP", zap.Error(err))
+ }
+
+ return &entity.ResendOTPResponse{
+ Token: token,
+ ExpiresAt: newExpiresAt,
+ Message: "OTP has been resent to your email and phone",
+ }, nil
+}
+
+func (s *memberSvc) sendRegistrationOTP(
+ ctx mycontext.Context,
+ registration *entity.MemberRegistration,
+) error {
+ emailData := map[string]interface{}{
+ "UserName": registration.Name,
+ "OTPCode": registration.OTP,
+ }
+
+ err := s.notification.SendEmailTransactional(ctx, entity.SendEmailNotificationParam{
+ Sender: "noreply@enaklo.co.id",
+ Recipient: registration.Email,
+ Subject: "Enaklo - Registration Verification Code",
+ TemplateName: "member_registration_otp",
+ TemplatePath: "templates/member_registration_otp.html",
+ Data: emailData,
+ })
+
+ if err != nil {
+ return err
+ }
+
+ //if registration.Phone != "" {
+ // smsMessage := fmt.Sprintf("Your Enaklo registration code is: %s. Please provide this code to our staff to complete your registration.", registration.OTP)
+ // _ = s.notification.SendSMS(ctx, registration.Phone, smsMessage)
+ //}
+
+ return nil
+}
+
+func (s *memberSvc) sendWelcomeEmail(
+ ctx mycontext.Context,
+ customer *entity.Customer,
+) error {
+
+ welcomeData := map[string]interface{}{
+ "UserName": customer.Name,
+ "MemberID": customer.CustomerID,
+ "PointsName": "PoinLo",
+ "PointsBalance": customer.Points,
+ "RedeemLink": "https://enaklo.co.id/redeem",
+ "CurrentDate": time.Now().Format("01-20006"),
+ }
+
+ return s.notification.SendEmailTransactional(ctx, entity.SendEmailNotificationParam{
+ Sender: "noreply@enaklo.co.id",
+ Recipient: customer.Email,
+ Subject: "Welcome to Enaklo Membership Program",
+ TemplateName: "welcome_member",
+ TemplatePath: "templates/welcome_member.html",
+ Data: welcomeData,
+ })
+}
+
+func generateOTP(length int) string {
+ rand.Seed(uint64(time.Now().Nanosecond()))
+ digits := "0123456789"
+ otp := ""
+ for i := 0; i < length; i++ {
+ otp += string(digits[rand.Intn(len(digits))])
+ }
+ return otp
+}
diff --git a/internal/services/service.go b/internal/services/service.go
index 39e5b82..8e7baca 100644
--- a/internal/services/service.go
+++ b/internal/services/service.go
@@ -6,6 +6,7 @@ import (
"enaklo-pos-be/internal/services/balance"
"enaklo-pos-be/internal/services/discovery"
service "enaklo-pos-be/internal/services/license"
+ "enaklo-pos-be/internal/services/member"
"enaklo-pos-be/internal/services/order"
"enaklo-pos-be/internal/services/oss"
"enaklo-pos-be/internal/services/partner"
@@ -42,9 +43,10 @@ type ServiceManagerImpl struct {
Balance Balance
DiscoverService DiscoverService
- OrderV2Svc orderSvc.Service
- CustomerV2Svc customerSvc.Service
- ProductV2Svc productSvc.Service
+ OrderV2Svc orderSvc.Service
+ CustomerV2Svc customerSvc.Service
+ ProductV2Svc productSvc.Service
+ MemberRegistrationSvc member.RegistrationService
}
func NewServiceManagerImpl(cfg *config.Config, repo *repository.RepoManagerImpl) *ServiceManagerImpl {
@@ -62,12 +64,14 @@ func NewServiceManagerImpl(cfg *config.Config, repo *repository.RepoManagerImpl)
OSSSvc: oss.NewOSSService(repo.OSS),
PartnerSvc: partner.NewPartnerService(
repo.Partner, users.NewUserService(repo.User), repo.Trx, repo.Wallet, repo.User),
- SiteSvc: site.NewSiteService(repo.Site, repo.User),
- LicenseSvc: service.NewLicenseService(repo.License),
- Transaction: transaction.New(repo.Transaction, repo.Wallet, repo.Trx),
- Balance: balance.NewBalanceService(repo.Wallet, repo.Trx, repo.Crypto, &cfg.Withdraw, repo.Transaction),
- DiscoverService: discovery.NewDiscoveryService(repo.Site, cfg.Discovery, repo.Product),
- OrderV2Svc: orderSvc.New(repo.OrderRepo, productSvcV2, custSvcV2, repo.TransactionRepo, repo.Crypto, &cfg.Order, repo.EmailService),
+ SiteSvc: site.NewSiteService(repo.Site, repo.User),
+ LicenseSvc: service.NewLicenseService(repo.License),
+ Transaction: transaction.New(repo.Transaction, repo.Wallet, repo.Trx),
+ Balance: balance.NewBalanceService(repo.Wallet, repo.Trx, repo.Crypto, &cfg.Withdraw, repo.Transaction),
+ DiscoverService: discovery.NewDiscoveryService(repo.Site, cfg.Discovery, repo.Product),
+ OrderV2Svc: orderSvc.New(repo.OrderRepo, productSvcV2, custSvcV2, repo.TransactionRepo, repo.Crypto, &cfg.Order, repo.EmailService),
+ MemberRegistrationSvc: member.NewMemberRegistrationService(repo.MemberRepository, repo.EmailService, custSvcV2),
+ CustomerV2Svc: custSvcV2,
}
}
diff --git a/internal/services/v2/customer/customer.go b/internal/services/v2/customer/customer.go
index ea45ad3..50660b1 100644
--- a/internal/services/v2/customer/customer.go
+++ b/internal/services/v2/customer/customer.go
@@ -5,6 +5,7 @@ import (
"enaklo-pos-be/internal/common/mycontext"
"enaklo-pos-be/internal/constants"
"enaklo-pos-be/internal/entity"
+ "enaklo-pos-be/internal/utils"
"github.com/pkg/errors"
"go.uber.org/zap"
"strings"
@@ -16,12 +17,16 @@ type Repository interface {
FindByPhone(ctx mycontext.Context, phone string) (*entity.Customer, error)
FindByEmail(ctx mycontext.Context, email string) (*entity.Customer, error)
AddPoints(ctx mycontext.Context, id int64, points int) error
+ FindSequence(ctx mycontext.Context, partnerID int64) (int64, error)
+ GetAllCustomers(ctx mycontext.Context, req entity.MemberSearch) (entity.MemberList, int, error)
}
type Service interface {
ResolveCustomer(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (int64, error)
AddPoints(ctx mycontext.Context, customerID int64, points int) error
GetCustomer(ctx mycontext.Context, id int64) (*entity.Customer, error)
+ CustomerCheck(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (*entity.CustomerCheckResponse, error)
+ GetAllCustomers(ctx mycontext.Context, req *entity.MemberSearch) (*entity.MemberList, int, error)
}
type customerSvc struct {
@@ -76,13 +81,20 @@ func (s *customerSvc) ResolveCustomer(ctx mycontext.Context, req *entity.Custome
return 0, errors.New("customer name is required to create a new customer")
}
+ lastSeq, err := s.repo.FindSequence(ctx, *ctx.GetPartnerID())
+ if err != nil {
+ return 0, errors.New("failed to resolve customer sequence")
+ }
+
newCustomer := &entity.Customer{
- Name: req.Name,
- Email: req.Email,
- Phone: req.PhoneNumber,
- Points: 0,
- CreatedAt: constants.TimeNow(),
- UpdatedAt: constants.TimeNow(),
+ Name: req.Name,
+ Email: req.Email,
+ Phone: req.PhoneNumber,
+ Points: 0,
+ CreatedAt: constants.TimeNow(),
+ UpdatedAt: constants.TimeNow(),
+ CustomerID: utils.GenerateMemberID(ctx, *ctx.GetPartnerID(), lastSeq),
+ BirthDate: req.BirthDate,
}
customer, err := s.repo.Create(ctx, newCustomer)
@@ -115,3 +127,78 @@ func (s *customerSvc) GetCustomer(ctx mycontext.Context, id int64) (*entity.Cust
return customer, nil
}
+
+func (s *customerSvc) CustomerCheck(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (*entity.CustomerCheckResponse, error) {
+ logger.ContextLogger(ctx).Info("checking customer existence before registration",
+ zap.String("email", req.Email),
+ zap.String("phone", req.PhoneNumber))
+
+ if req.Email == "" && req.PhoneNumber == "" {
+ return nil, errors.New("email dan phone number is mandatory")
+ }
+
+ response := &entity.CustomerCheckResponse{
+ Exists: false,
+ Customer: nil,
+ }
+
+ if req.PhoneNumber != "" {
+ customer, err := s.repo.FindByPhone(ctx, req.PhoneNumber)
+ if err != nil {
+ if !strings.Contains(err.Error(), "not found") {
+ logger.ContextLogger(ctx).Error("error checking customer by phone", zap.Error(err))
+ return nil, errors.Wrap(err, "failed to find customer by phone")
+ }
+ } else {
+ logger.ContextLogger(ctx).Info("found existing customer by phone",
+ zap.Int64("customerId", customer.ID))
+
+ return &entity.CustomerCheckResponse{
+ Exists: true,
+ Customer: customer,
+ Message: "Customer already exists with this phone number",
+ }, nil
+ }
+ }
+
+ if req.Email != "" {
+ customer, err := s.repo.FindByEmail(ctx, req.Email)
+ if err != nil {
+ if !strings.Contains(err.Error(), "not found") {
+ logger.ContextLogger(ctx).Error("error checking customer by email", zap.Error(err))
+ return nil, errors.Wrap(err, "failed to find customer by email")
+ }
+ } else {
+ logger.ContextLogger(ctx).Info("found existing customer by email",
+ zap.Int64("customerId", customer.ID))
+
+ return &entity.CustomerCheckResponse{
+ Exists: true,
+ Customer: customer,
+ Message: "Customer already exists with this email",
+ }, nil
+ }
+ }
+
+ return response, nil
+}
+
+func (s *customerSvc) GetAllCustomers(ctx mycontext.Context, req *entity.MemberSearch) (*entity.MemberList, int, error) {
+ if req.Limit <= 0 {
+ req.Limit = 10
+ }
+ if req.Offset < 0 {
+ req.Offset = 0
+ }
+
+ customers, totalCount, err := s.repo.GetAllCustomers(ctx, *req)
+ if err != nil {
+ logger.ContextLogger(ctx).Error("failed to retrieve customers",
+ zap.Error(err),
+ zap.String("search", req.Search),
+ )
+ return nil, 0, errors.Wrap(err, "failed to get customers")
+ }
+
+ return &customers, totalCount, nil
+}
diff --git a/internal/services/v2/member/member.go b/internal/services/v2/member/member.go
new file mode 100644
index 0000000..363b41e
--- /dev/null
+++ b/internal/services/v2/member/member.go
@@ -0,0 +1 @@
+package member
diff --git a/internal/services/v2/order/execute_order.go b/internal/services/v2/order/execute_order.go
index d8a43fa..d1804f7 100644
--- a/internal/services/v2/order/execute_order.go
+++ b/internal/services/v2/order/execute_order.go
@@ -136,6 +136,9 @@ func (s *orderSvc) sendTransactionReceipt(ctx mycontext.Context, order *entity.O
emailData := map[string]interface{}{
"UserName": customer.Name,
+ "PointsName": "PoinLo",
+ "PointsBalance": "20",
+ "RedeemLink": "enaklo.co.id",
"BranchName": branchName,
"TransactionNumber": order.ID,
"TransactionDate": transactionDate,
@@ -148,9 +151,9 @@ func (s *orderSvc) sendTransactionReceipt(ctx mycontext.Context, order *entity.O
return s.notification.SendEmailTransactional(ctx, entity.SendEmailNotificationParam{
Sender: "noreply@enaklo.co.id",
Recipient: customer.Email,
- Subject: "Enaklo - Resi Pembelian",
- TemplateName: "transaction_receipt",
- TemplatePath: "templates/transaction_receipt.html",
+ Subject: "Enaklo - Membership Statement",
+ TemplateName: "monthly_points",
+ TemplatePath: "templates/monthly_points.html",
Data: emailData,
})
}
diff --git a/internal/utils/member_generator.go b/internal/utils/member_generator.go
new file mode 100644
index 0000000..ba04719
--- /dev/null
+++ b/internal/utils/member_generator.go
@@ -0,0 +1,14 @@
+package utils
+
+import (
+ "enaklo-pos-be/internal/common/mycontext"
+ "fmt"
+ "time"
+)
+
+func GenerateMemberID(ctx mycontext.Context, branchID, sequence int64) string {
+ now := time.Now()
+ yearMonth := now.Format("200601")
+
+ return fmt.Sprintf("%s%04d%04d", yearMonth, branchID, sequence)
+}
diff --git a/templates/member_registration_otp.html b/templates/member_registration_otp.html
new file mode 100644
index 0000000..e6af64a
--- /dev/null
+++ b/templates/member_registration_otp.html
@@ -0,0 +1,217 @@
+
+
+
+
+ Kode Verifikasi Pendaftaran Member Enaklo
+
+
+
+
+
+
+
+
+
+

+
Kode Verifikasi Pendaftaran Member
+
+ Hai {{ .UserName }},
+ Terima kasih telah mendaftar sebagai member Enaklo. Berikan kode verifikasi berikut kepada staf kasir kami untuk menyelesaikan pendaftaran Anda:
+
+
{{ .OTPCode }}
+
Kode ini berlaku selama 10 menit
+
+
+ Cara Menyelesaikan Pendaftaran:
+ 1. Tunjukkan email ini kepada staf kasir Enaklo
+ 2. Staf akan memverifikasi identitas Anda
+ 3. Staf akan memasukkan kode OTP ini ke sistem POS
+ 4. Pendaftaran member Anda akan segera aktif!
+
+
+
+ Dengan menjadi member Enaklo, Anda akan menikmati berbagai keuntungan eksklusif:
+
+
+
+
+ ✓ Kumpulkan poin dengan setiap pembelian
+
+
+ ✓ Diskon khusus member pada menu pilihan
+
+
+ ✓ Penawaran eksklusif saat ulang tahun Anda
+
+
+ ✓ Akses awal ke menu baru dan promo spesial
+
+
+
+
+
+ Jika Anda tidak sedang mendaftar sebagai member Enaklo, abaikan email ini atau hubungi tim support kami segera.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/welcome_member.html b/templates/welcome_member.html
new file mode 100644
index 0000000..a725ce8
--- /dev/null
+++ b/templates/welcome_member.html
@@ -0,0 +1,331 @@
+
+
+
+
+ Selamat Datang di Enaklo Membership
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 
+
+
+ Selamat Datang di Program Membership Enaklo!
+
+
+
+ Halo {{ .UserName }},
+ Terima kasih telah bergabung dengan program membership Enaklo. Kami senang Anda menjadi bagian dari keluarga kami!
+
+
+
+
+
+
+
+ |
+ {{ .UserName }}
+ ID Member: {{ .MemberID }}
+
+
+
+ |
+ {{ .PointsName }}
+ {{ .PointsBalance }}
+ |
+
+
+ |
+
+
+ Member sejak {{ .CurrentDate }}
+ |
+
+
+ |
+
+
+
+
+ Sebagai member Enaklo, Anda berhak mendapatkan berbagai keuntungan eksklusif.
+
+
+
+
+
+
+ 1
+
+ Kumpulkan {{ .PointsName }}
+ Dapatkan {{ .PointsName }} setiap kali Anda bertransaksi di Enaklo. Setiap Rp 1.000 = 1 {{ .PointsName }}.
+
+
+
+
+
+ 2
+
+ Tukarkan dengan Reward Menarik
+ {{ .PointsName }} Anda dapat ditukarkan dengan berbagai menu favorit atau diskon khusus.
+
+
+
+
+
+ 3
+
+ Penawaran Eksklusif
+ Dapatkan akses ke penawaran dan promosi khusus yang hanya tersedia untuk member.
+
+
+
+
+
+ 4
+
+ Kejutan di Hari Spesial
+ Dapatkan hadiah spesial di hari ulang tahun Anda dan acara spesial lainnya.
+
+
+
+
+
+ Untuk melihat {{ .PointsName }} Anda dan menukarkan hadiah, kunjungi halaman rewards kami.
+
+
+
+
+
+
+
+
+ Tunjukkan kartu member digital Anda (di email ini) atau sebutkan nomor telepon Anda saat bertransaksi di Enaklo untuk mengumpulkan {{ .PointsName }}.
+
+
+
+
+ |
+
+
+
+
+
+
\ No newline at end of file