feat(loyalty): EnakPoint payment method
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fe2f459b03
commit
cf5332c281
+1
-1
@@ -414,7 +414,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
|||||||
productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo),
|
productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo),
|
||||||
inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo),
|
inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo),
|
||||||
orderProcessor: orderProcessor,
|
orderProcessor: orderProcessor,
|
||||||
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo),
|
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo, loyaltySettingsProcessor),
|
||||||
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
|
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
|
||||||
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
|
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
|
||||||
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo),
|
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type CreatePaymentMethodRequest struct {
|
|||||||
|
|
||||||
type UpdatePaymentMethodRequest struct {
|
type UpdatePaymentMethodRequest struct {
|
||||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"`
|
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"`
|
Processor *string `json:"processor,omitempty" validate:"omitempty,max=100"`
|
||||||
Configuration map[string]interface{} `json:"configuration,omitempty"`
|
Configuration map[string]interface{} `json:"configuration,omitempty"`
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
@@ -38,11 +38,13 @@ type PaymentMethodResponse struct {
|
|||||||
|
|
||||||
type ListPaymentMethodsRequest struct {
|
type ListPaymentMethodsRequest struct {
|
||||||
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
||||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"`
|
// When set, EnakPoint is left out unless the outlet accepts it (F9).
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
Search string `json:"search,omitempty"`
|
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"`
|
||||||
Page int `json:"page" validate:"min=1"`
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
Search string `json:"search,omitempty"`
|
||||||
|
Page int `json:"page" validate:"min=1"`
|
||||||
|
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListPaymentMethodsResponse struct {
|
type ListPaymentMethodsResponse struct {
|
||||||
|
|||||||
@@ -13,13 +13,16 @@ const (
|
|||||||
PaymentMethodTypeCash PaymentMethodType = "cash"
|
PaymentMethodTypeCash PaymentMethodType = "cash"
|
||||||
PaymentMethodTypeCard PaymentMethodType = "card"
|
PaymentMethodTypeCard PaymentMethodType = "card"
|
||||||
PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet"
|
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 {
|
type PaymentMethod struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
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"`
|
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"`
|
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"`
|
Processor *string `gorm:"size:100" json:"processor"`
|
||||||
Configuration Metadata `gorm:"type:jsonb;default:'{}'" json:"configuration"`
|
Configuration Metadata `gorm:"type:jsonb;default:'{}'" json:"configuration"`
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
@@ -69,12 +72,16 @@ type Payment struct {
|
|||||||
SplitType *SplitType `gorm:"size:20" json:"split_type,omitempty"`
|
SplitType *SplitType `gorm:"size:20" json:"split_type,omitempty"`
|
||||||
SplitDescription *string `gorm:"size:255" json:"split_description,omitempty"`
|
SplitDescription *string `gorm:"size:255" json:"split_description,omitempty"`
|
||||||
RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"`
|
RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"`
|
||||||
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
|
// Set only for a payment with EnakPoint: how many were used, and the rupiah value of
|
||||||
RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"`
|
// one then, frozen so a refund returns exactly what was used.
|
||||||
RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"`
|
PointsUsed *int64 `json:"points_used,omitempty"`
|
||||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
PointValue *float64 `gorm:"type:decimal(10,2)" json:"point_value,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
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"`
|
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||||
PaymentMethod PaymentMethod `gorm:"foreignKey:PaymentMethodID" json:"payment_method,omitempty"`
|
PaymentMethod PaymentMethod `gorm:"foreignKey:PaymentMethodID" json:"payment_method,omitempty"`
|
||||||
|
|||||||
@@ -113,6 +113,16 @@ func (h *PaymentMethodHandler) ListPaymentMethods(c *gin.Context) {
|
|||||||
|
|
||||||
req.OrganizationID = &contextInfo.OrganizationID
|
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 isActiveStr := c.Query("is_active"); isActiveStr != "" {
|
||||||
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
|
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
|
||||||
req.IsActive = &isActive
|
req.IsActive = &isActive
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ func ListPaymentMethodsContractToModel(req *contract.ListPaymentMethodsRequest)
|
|||||||
|
|
||||||
return &models.ListPaymentMethodsRequest{
|
return &models.ListPaymentMethodsRequest{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
Type: paymentMethodType,
|
Type: paymentMethodType,
|
||||||
IsActive: req.IsActive,
|
IsActive: req.IsActive,
|
||||||
Search: req.Search,
|
Search: req.Search,
|
||||||
|
|||||||
@@ -51,11 +51,13 @@ type PaymentMethodResponse struct {
|
|||||||
|
|
||||||
type ListPaymentMethodsRequest struct {
|
type ListPaymentMethodsRequest struct {
|
||||||
OrganizationID *uuid.UUID
|
OrganizationID *uuid.UUID
|
||||||
Type *constants.PaymentMethodType
|
// When set, EnakPoint is left out unless the outlet accepts it (F9).
|
||||||
IsActive *bool
|
OutletID *uuid.UUID
|
||||||
Search string
|
Type *constants.PaymentMethodType
|
||||||
Page int `validate:"min=1"`
|
IsActive *bool
|
||||||
Limit int `validate:"min=1,max=100"`
|
Search string
|
||||||
|
Page int `validate:"min=1"`
|
||||||
|
Limit int `validate:"min=1,max=100"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListPaymentMethodsResponse struct {
|
type ListPaymentMethodsResponse struct {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package processor
|
package processor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"apskel-pos-be/internal/mappers"
|
"apskel-pos-be/internal/mappers"
|
||||||
@@ -20,17 +23,26 @@ type PaymentMethodProcessor interface {
|
|||||||
GetActivePaymentMethodsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]models.PaymentMethodResponse, error)
|
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 {
|
type PaymentMethodProcessorImpl struct {
|
||||||
paymentMethodRepo repository.PaymentMethodRepository
|
paymentMethodRepo repository.PaymentMethodRepository
|
||||||
|
outletSettings outletSettingsReader
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository) *PaymentMethodProcessorImpl {
|
func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository, outletSettings outletSettingsReader) *PaymentMethodProcessorImpl {
|
||||||
return &PaymentMethodProcessorImpl{
|
return &PaymentMethodProcessorImpl{
|
||||||
paymentMethodRepo: paymentMethodRepo,
|
paymentMethodRepo: paymentMethodRepo,
|
||||||
|
outletSettings: outletSettings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PaymentMethodProcessorImpl) CreatePaymentMethod(ctx context.Context, req *models.CreatePaymentMethodRequest) (*models.PaymentMethodResponse, error) {
|
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)
|
exists, err := p.paymentMethodRepo.ExistsByName(ctx, req.OrganizationID, req.Name, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check payment method name uniqueness: %w", err)
|
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 != "" {
|
if req.Search != "" {
|
||||||
filters["search"] = 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
|
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)
|
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
|
// Check name uniqueness if name is being updated
|
||||||
if req.Name != nil && *req.Name != existingPaymentMethod.Name {
|
if req.Name != nil && *req.Name != existingPaymentMethod.Name {
|
||||||
exists, err := p.paymentMethodRepo.ExistsByName(ctx, existingPaymentMethod.OrganizationID, *req.Name, &id)
|
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 {
|
func (p *PaymentMethodProcessorImpl) DeletePaymentMethod(ctx context.Context, id uuid.UUID) error {
|
||||||
// Check if payment method exists
|
// Check if payment method exists
|
||||||
_, err := p.paymentMethodRepo.GetByID(ctx, id)
|
existing, err := p.paymentMethodRepo.GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("payment method not found: %w", err)
|
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
|
// TODO: Check if payment method is being used in any payments
|
||||||
// For now, allow deletion
|
// For now, allow deletion
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -75,6 +75,8 @@ func (r *PaymentMethodRepositoryImpl) List(ctx context.Context, filters map[stri
|
|||||||
case "search":
|
case "search":
|
||||||
searchValue := "%" + value.(string) + "%"
|
searchValue := "%" + value.(string) + "%"
|
||||||
query = query.Where("name ILIKE ? OR processor ILIKE ?", searchValue, searchValue)
|
query = query.Where("name ILIKE ? OR processor ILIKE ?", searchValue, searchValue)
|
||||||
|
case "exclude_type":
|
||||||
|
query = query.Where("type <> ?", value)
|
||||||
default:
|
default:
|
||||||
query = query.Where(key+" = ?", value)
|
query = query.Where(key+" = ?", value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"apskel-pos-be/internal/appcontext"
|
"apskel-pos-be/internal/appcontext"
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/contract"
|
"apskel-pos-be/internal/contract"
|
||||||
"apskel-pos-be/internal/mappers"
|
"apskel-pos-be/internal/mappers"
|
||||||
"apskel-pos-be/internal/processor"
|
"apskel-pos-be/internal/processor"
|
||||||
@@ -39,7 +41,7 @@ func (s *PaymentMethodServiceImpl) CreatePaymentMethod(ctx context.Context, cont
|
|||||||
response, err := s.paymentMethodProcessor.CreatePaymentMethod(ctx, modelReq)
|
response, err := s.paymentMethodProcessor.CreatePaymentMethod(ctx, modelReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contract.BuildErrorResponse([]*contract.ResponseError{
|
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)
|
response, err := s.paymentMethodProcessor.UpdatePaymentMethod(ctx, id, modelReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contract.BuildErrorResponse([]*contract.ResponseError{
|
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)
|
err := s.paymentMethodProcessor.DeletePaymentMethod(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contract.BuildErrorResponse([]*contract.ResponseError{
|
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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ func (v *PaymentMethodValidatorImpl) isValidPaymentMethodType(paymentMethodType
|
|||||||
string(constants.PaymentMethodTypeDigitalWallet),
|
string(constants.PaymentMethodTypeDigitalWallet),
|
||||||
string(constants.PaymentMethodTypeQR),
|
string(constants.PaymentMethodTypeQR),
|
||||||
string(constants.PaymentMethodTypeEDC),
|
string(constants.PaymentMethodTypeEDC),
|
||||||
|
string(constants.PaymentMethodTypePoint),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, validType := range validTypes {
|
for _, validType := range validTypes {
|
||||||
|
|||||||
@@ -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'));
|
||||||
@@ -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)));
|
||||||
Reference in New Issue
Block a user