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,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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user