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)));