Compare commits

..
Author SHA1 Message Date
ryan b90a3cde4a Fix coa summary zero 2026-06-03 13:00:50 +07:00
ryan 7c8c7fb7db update expense coa 2026-06-02 16:32:20 +07:00
18 changed files with 227 additions and 369 deletions
-6
View File
@@ -83,12 +83,6 @@ migration-up:
migration-down:
@migrate -database $(DB_URL) -path ./migrations down 1
# Force migration to specific version
.SILENT: migration-force
migration-force:
@migrate -database $(DB_URL) -path ./migrations force $(version)
.SILENT: seeder-create
seeder-create:
@migrate create -ext sql -dir ./seeders -seq $(name)
+2 -4
View File
@@ -48,7 +48,6 @@ func (a *App) Initialize(cfg *config.Config) error {
// Initialize omset milestone scheduler
a.omsetScheduler = service.NewOmsetMilestoneScheduler(
repos.organizationRepo,
repos.outletRepo,
repos.userRepo,
processors.notificationProcessor,
)
@@ -138,16 +137,15 @@ func (a *App) Initialize(cfg *config.Config) error {
selfOrderHandler,
services.expenseService,
validators.expenseValidator,
a.redisClient,
)
return nil
}
func (a *App) Start(port string) error {
// Start the omset milestone scheduler (checks every 5 minutes for daily omset milestones)
// Start the omset milestone scheduler (checks every hour)
if a.omsetScheduler != nil {
a.omsetScheduler.Start(5 * time.Minute)
a.omsetScheduler.Start(1 * time.Hour)
}
engine := a.router.Init()
+1 -3
View File
@@ -1,8 +1,6 @@
package handler
import (
"apskel-pos-be/internal/logger"
"fmt"
"net/http"
"time"
)
@@ -49,7 +47,7 @@ func (m *CommonMiddleware) Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
logger.FromContext(r.Context()).Error("Recovery", fmt.Sprintf("panic recovered: %v", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
-137
View File
@@ -1,137 +0,0 @@
package middleware
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
const (
IdempotencyKeyHeader = "X-Idempotency-Key"
idempotencyTTL = 24 * time.Hour
idempotencyPrefix = "idempotency:"
)
type cachedResponse struct {
StatusCode int `json:"status_code"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
// IdempotencyMiddleware returns a Gin middleware that ensures idempotent processing
// for mutating operations. Client must send X-Idempotency-Key header.
func IdempotencyMiddleware(redisClient *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
key := c.GetHeader(IdempotencyKeyHeader)
if key == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"success": false,
"errors": []gin.H{
{
"code": "missing_idempotency_key",
"entity": "IdempotencyMiddleware",
"cause": "X-Idempotency-Key header is required",
},
},
})
return
}
redisKey := fmt.Sprintf("%s%s", idempotencyPrefix, key)
ctx := context.Background()
fmt.Printf("[DEBUG] IdempotencyMiddleware: key=%s redisKey=%s\n", key, redisKey)
// Check if key already exists (request was already processed)
cached, err := redisClient.Get(ctx, redisKey).Result()
if err == nil {
// Key exists — return cached response
fmt.Printf("[DEBUG] IdempotencyMiddleware: cache HIT for key=%s\n", key)
var resp cachedResponse
if err := json.Unmarshal([]byte(cached), &resp); err == nil {
for k, v := range resp.Headers {
c.Writer.Header().Set(k, v)
}
c.Writer.Header().Set("X-Idempotent-Replay", "true")
c.Data(resp.StatusCode, "application/json", []byte(resp.Body))
c.Abort()
return
}
} else {
fmt.Printf("[DEBUG] IdempotencyMiddleware: cache MISS for key=%s err=%v\n", key, err)
}
// Mark key as in-progress to prevent concurrent duplicates
set, err := redisClient.SetNX(ctx, redisKey, "processing", idempotencyTTL).Result()
if err != nil {
// Redis error — proceed without idempotency (fail open)
c.Next()
return
}
if !set {
// Another request with the same key is being processed
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
"success": false,
"errors": []gin.H{
{
"code": "request_in_progress",
"entity": "IdempotencyMiddleware",
"cause": "A request with this idempotency key is already being processed",
},
},
})
return
}
// Capture response using a custom writer
writer := &responseCapture{
ResponseWriter: c.Writer,
body: &bytes.Buffer{},
}
c.Writer = writer
c.Next()
// After handler completes, cache the response only if successful (2xx)
statusCode := writer.Status()
if statusCode >= 200 && statusCode < 300 {
resp := cachedResponse{
StatusCode: statusCode,
Headers: map[string]string{
"Content-Type": writer.Header().Get("Content-Type"),
},
Body: writer.body.String(),
}
respJSON, err := json.Marshal(resp)
if err == nil {
redisClient.Set(ctx, redisKey, string(respJSON), idempotencyTTL)
}
} else {
// Remove the in-progress key so the client can retry with the same key
redisClient.Del(ctx, redisKey)
}
}
}
// responseCapture wraps gin.ResponseWriter to capture the response body
type responseCapture struct {
gin.ResponseWriter
body *bytes.Buffer
}
func (w *responseCapture) Write(b []byte) (int, error) {
w.body.Write(b)
return w.ResponseWriter.Write(b)
}
func (w *responseCapture) WriteString(s string) (int, error) {
w.body.WriteString(s)
return w.ResponseWriter.WriteString(s)
}
+88 -31
View File
@@ -6,7 +6,6 @@ import (
"strings"
"time"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
@@ -453,15 +452,46 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
}
}
todayPromosi := getExpenseAmountByCategory(result.TodayExpenseByCategory, "promosi")
todayLainLain := getExpenseAmountByCategory(result.TodayExpenseByCategory, "lain")
todayTotalOps := todayPromosi + todayLainLain
todayGaji := getExpenseAmountByCategory(result.TodayExpenseByCategory, "gaji")
type categoryAmount struct {
Name string
TodayAmt float64
MtdAmt float64
}
mtdPromosi := getExpenseAmountByCategory(result.MtdExpenseByCategory, "promosi")
mtdLainLain := getExpenseAmountByCategory(result.MtdExpenseByCategory, "lain")
mtdTotalOps := mtdPromosi + mtdLainLain
mtdGaji := getExpenseAmountByCategory(result.MtdExpenseByCategory, "gaji")
categoryMap := make(map[string]*categoryAmount)
var categoryOrder []string
for _, cat := range result.TodayExpenseByCategory {
name := cat.CategoryName
if _, exists := categoryMap[name]; !exists {
categoryMap[name] = &categoryAmount{Name: name}
categoryOrder = append(categoryOrder, name)
}
categoryMap[name].TodayAmt = cat.Amount
}
for _, cat := range result.MtdExpenseByCategory {
name := cat.CategoryName
if _, exists := categoryMap[name]; !exists {
categoryMap[name] = &categoryAmount{Name: name}
categoryOrder = append(categoryOrder, name)
}
categoryMap[name].MtdAmt = cat.Amount
}
var todayTotalOps float64
var mtdTotalOps float64
var todayGaji float64
var mtdGaji float64
for _, cat := range categoryMap {
if isSalaryExpenseCategory(cat.Name) {
todayGaji += cat.TodayAmt
mtdGaji += cat.MtdAmt
continue
}
todayTotalOps += cat.TodayAmt
mtdTotalOps += cat.MtdAmt
}
todayGrossProfit := result.TodayRevenue - result.TodayCost
mtdGrossProfit := result.MtdRevenue - result.MtdCost
@@ -485,6 +515,33 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
return (nominal / result.MtdRevenue) * 100
}
opsSubItems := make([]models.ProfitLossSummaryRow, 0, len(categoryOrder)+1)
opsCategoryCount := 0
for _, name := range categoryOrder {
cat := categoryMap[name]
if isSalaryExpenseCategory(cat.Name) {
continue
}
opsCategoryCount++
opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{
ID: fmt.Sprintf("by_%s", slugify(name)),
Label: fmt.Sprintf("%d. %s", opsCategoryCount, cat.Name),
TodayNominal: cat.TodayAmt,
TodayPct: todayPct(cat.TodayAmt),
MtdNominal: cat.MtdAmt,
MtdPct: mtdPct(cat.MtdAmt),
})
}
opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{
ID: "total_biaya_ops",
Label: fmt.Sprintf("Total Biaya OPS (%d kategori)", opsCategoryCount),
IsBold: true,
TodayNominal: todayTotalOps,
TodayPct: todayPct(todayTotalOps),
MtdNominal: mtdTotalOps,
MtdPct: mtdPct(mtdTotalOps),
})
mainSummary := []models.ProfitLossSummaryRow{
{
ID: "total_omset", Label: "TOTAL OMSET",
@@ -505,23 +562,7 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
ID: "biaya_ops", Label: "BIAYA OPS",
TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps),
MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps),
SubItems: []models.ProfitLossSummaryRow{
{
ID: "by_promosi", Label: "1. By Promosi",
TodayNominal: todayPromosi, TodayPct: todayPct(todayPromosi),
MtdNominal: mtdPromosi, MtdPct: mtdPct(mtdPromosi),
},
{
ID: "by_lain_lain", Label: "2. By Lain lain",
TodayNominal: todayLainLain, TodayPct: todayPct(todayLainLain),
MtdNominal: mtdLainLain, MtdPct: mtdPct(mtdLainLain),
},
{
ID: "total_biaya_ops", Label: "Total Biaya OPS (4.1+4.2)", IsBold: true,
TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps),
MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps),
},
},
SubItems: opsSubItems,
},
{
ID: "laba_rugi_sblm_gaji", Label: "Laba/Rugi sblm Gaji (3-4)",
@@ -577,11 +618,27 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
}, nil
}
func getExpenseAmountByCategory(categories []entities.ExpenseCategoryTotal, keyword string) float64 {
for _, cat := range categories {
if strings.Contains(strings.ToLower(cat.CategoryName), keyword) {
return cat.Amount
func isSalaryExpenseCategory(name string) bool {
name = strings.ToLower(name)
return strings.Contains(name, "gaji") || strings.Contains(name, "salary")
}
func slugify(s string) string {
result := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z':
result = append(result, c)
case c >= 'A' && c <= 'Z':
result = append(result, c+32)
case c >= '0' && c <= '9':
result = append(result, c)
default:
if len(result) == 0 || result[len(result)-1] != '_' {
result = append(result, '_')
}
}
}
return 0
return string(result)
}
@@ -165,3 +165,83 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *
require.NotEmpty(t, result.MainSummary)
require.Equal(t, "total_omset", result.MainSummary[0].ID)
}
func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) {
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
profitLossResult: &entities.ProfitLossAnalytics{
Summary: entities.ProfitLossSummary{
TotalRevenue: 10000,
TotalCost: 4000,
},
TodayRevenue: 10000,
TodayCost: 4000,
MtdRevenue: 20000,
MtdCost: 8000,
TodayExpenseByCategory: []entities.ExpenseCategoryTotal{
{CategoryName: "Gaji", Amount: 1500},
{CategoryName: "Promosi", Amount: 300},
{CategoryName: "Sewa", Amount: 500},
},
MtdExpenseByCategory: []entities.ExpenseCategoryTotal{
{CategoryName: "Gaji", Amount: 3000},
{CategoryName: "Promosi", Amount: 600},
{CategoryName: "Sewa", Amount: 1000},
},
},
}, expenseRepositoryStub{})
result, err := processor.GetProfitLossAnalytics(context.Background(), &models.ProfitLossAnalyticsRequest{
OrganizationID: uuid.New(),
DateFrom: now,
DateTo: now,
})
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, result.MainSummary, 7)
require.Equal(t, "total_omset", result.MainSummary[0].ID)
require.Equal(t, float64(10000), result.MainSummary[0].TodayNominal)
require.Equal(t, float64(20000), result.MainSummary[0].MtdNominal)
require.Equal(t, "hpp", result.MainSummary[1].ID)
require.Equal(t, float64(4000), result.MainSummary[1].TodayNominal)
require.Equal(t, float64(8000), result.MainSummary[1].MtdNominal)
require.Equal(t, "laba_kotor", result.MainSummary[2].ID)
require.Equal(t, float64(6000), result.MainSummary[2].TodayNominal)
require.Equal(t, float64(12000), result.MainSummary[2].MtdNominal)
require.Equal(t, "biaya_ops", result.MainSummary[3].ID)
require.Equal(t, float64(800), result.MainSummary[3].TodayNominal)
require.Equal(t, float64(1600), result.MainSummary[3].MtdNominal)
require.Len(t, result.MainSummary[3].SubItems, 3) // 2 operational categories + 1 total
require.Equal(t, "by_promosi", result.MainSummary[3].SubItems[0].ID)
require.Equal(t, float64(300), result.MainSummary[3].SubItems[0].TodayNominal)
require.Equal(t, float64(600), result.MainSummary[3].SubItems[0].MtdNominal)
require.Equal(t, "by_sewa", result.MainSummary[3].SubItems[1].ID)
require.Equal(t, float64(500), result.MainSummary[3].SubItems[1].TodayNominal)
require.Equal(t, float64(1000), result.MainSummary[3].SubItems[1].MtdNominal)
require.Equal(t, "total_biaya_ops", result.MainSummary[3].SubItems[2].ID)
require.True(t, result.MainSummary[3].SubItems[2].IsBold)
require.Equal(t, float64(800), result.MainSummary[3].SubItems[2].TodayNominal)
require.Equal(t, float64(1600), result.MainSummary[3].SubItems[2].MtdNominal)
require.Equal(t, "laba_rugi_sblm_gaji", result.MainSummary[4].ID)
require.Equal(t, float64(5200), result.MainSummary[4].TodayNominal)
require.Equal(t, float64(10400), result.MainSummary[4].MtdNominal)
require.Equal(t, "biaya_gaji", result.MainSummary[5].ID)
require.Equal(t, float64(1500), result.MainSummary[5].TodayNominal)
require.Equal(t, float64(3000), result.MainSummary[5].MtdNominal)
require.Equal(t, "laba_rugi", result.MainSummary[6].ID)
require.Equal(t, float64(3700), result.MainSummary[6].TodayNominal)
require.Equal(t, float64(7400), result.MainSummary[6].MtdNominal)
require.True(t, result.MainSummary[6].IsBold)
}
+2 -12
View File
@@ -338,7 +338,7 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
ProductID: itemReq.ProductID,
ProductVariantID: itemReq.ProductVariantID,
Quantity: itemReq.Quantity,
UnitPrice: unitPrice,
UnitPrice: unitPrice, // Use price from database
TotalPrice: itemTotalPrice,
UnitCost: unitCost,
TotalCost: itemTotalCost,
@@ -594,10 +594,6 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
return fmt.Errorf("order item does not belong to this order")
}
if orderItem.Status == entities.OrderItemStatusCancelled {
return fmt.Errorf("order item %s is already cancelled", orderItemID)
}
if itemVoid.Quantity > orderItem.Quantity {
return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID)
}
@@ -618,15 +614,9 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
return fmt.Errorf("outlet not found: %w", err)
}
// Reload order to get latest state
order, err = p.orderRepo.GetByID(ctx, req.OrderID)
if err != nil {
return fmt.Errorf("failed to reload order: %w", err)
}
order.Subtotal -= totalVoidedAmount
order.TotalCost -= totalVoidedCost
order.TaxAmount = order.Subtotal * outlet.TaxRate
order.TaxAmount = order.Subtotal * outlet.TaxRate // Recalculate tax using outlet's tax rate
order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount
if err := p.orderRepo.Update(ctx, order); err != nil {
@@ -235,7 +235,7 @@ func (p *ProductRecipeProcessorImpl) entityToResponse(entity *entities.ProductRe
CreatedAt: entity.Ingredient.CreatedAt,
UpdatedAt: entity.Ingredient.UpdatedAt,
}
// Add unit if available
if entity.Ingredient.Unit != nil {
symbol := ""
@@ -253,4 +253,4 @@ func (p *ProductRecipeProcessorImpl) entityToResponse(entity *entities.ProductRe
}
return response
}
}
@@ -22,8 +22,6 @@ const (
MetadataKeyLastSplitQuantities = "last_split_quantities"
)
type SplitBillValidation struct {
OrderItems map[uuid.UUID]*entities.OrderItem
PaidQuantities map[uuid.UUID]int
+3 -3
View File
@@ -637,7 +637,7 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga
query := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`COALESCE(parent_coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount`).
Select(`COALESCE(parent_coa.name, coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount`).
Joins("JOIN expenses e ON ei.expense_id = e.id").
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.id").
@@ -650,8 +650,8 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga
}
err := query.
Group("parent_coa.name").
Order("parent_coa.name").
Group("COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
Order("COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
Scan(&results).Error
return results, err
@@ -173,4 +173,3 @@ func (r *IngredientUnitConverterRepositoryImpl) ConvertQuantity(ctx context.Cont
// If no converter found, return error
return 0, fmt.Errorf("no conversion found between units %s and %s for ingredient %s", fromUnitID, toUnitID, ingredientID)
}
@@ -2,8 +2,6 @@ package repository
import (
"context"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/entities"
@@ -112,29 +110,3 @@ func (r *OrganizationRepositoryImpl) GetTotalOmset(ctx context.Context, organiza
Scan(&total).Error
return total, err
}
// GetTodayOmset returns the total revenue from completed orders for an organization on the current calendar day.
func (r *OrganizationRepositoryImpl) GetTodayOmset(ctx context.Context, organizationID uuid.UUID) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("orders").
Where(
"organization_id = ? AND payment_status = ? AND is_void = ? AND is_refund = ? AND created_at >= ? AND created_at < ?",
organizationID, "completed", false, false,
todayStart(), tomorrowStart(),
).
Select("COALESCE(SUM(total_amount), 0)").
Scan(&total).Error
return total, err
}
// todayStart returns midnight of the current local day.
func todayStart() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
}
// tomorrowStart returns midnight of the next local day.
func tomorrowStart() time.Time {
return todayStart().AddDate(0, 0, 1)
}
-20
View File
@@ -3,7 +3,6 @@ package repository
import (
"apskel-pos-be/internal/entities"
"context"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
@@ -104,22 +103,3 @@ func (r *OutletRepositoryImpl) Count(ctx context.Context, filters map[string]int
err := query.Count(&count).Error
return count, err
}
// GetTodayOmset returns the total revenue from completed orders for an outlet on the current calendar day.
func (r *OutletRepositoryImpl) GetTodayOmset(ctx context.Context, outletID uuid.UUID) (float64, error) {
var total float64
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
tomorrowStart := todayStart.AddDate(0, 0, 1)
err := r.db.WithContext(ctx).
Table("orders").
Where(
"outlet_id = ? AND payment_status = ? AND is_void = ? AND is_refund = ? AND created_at >= ? AND created_at < ?",
outletID, "completed", false, false,
todayStart, tomorrowStart,
).
Select("COALESCE(SUM(total_amount), 0)").
Scan(&total).Error
return total, err
}
-13
View File
@@ -2,7 +2,6 @@ package repository
import (
"context"
"database/sql"
"gorm.io/gorm"
)
@@ -38,15 +37,3 @@ func (m *TxManager) WithTransaction(ctx context.Context, fn func(ctx context.Con
return fn(ctxTx)
})
}
// WithTransactionOptions runs fn inside a DB transaction with custom TxOptions (e.g. isolation level).
func (m *TxManager) WithTransactionOptions(ctx context.Context, opts *sql.TxOptions, fn func(ctx context.Context) error) error {
if m == nil || m.db == nil {
return fn(ctx)
}
return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
ctxTx := context.WithValue(ctx, txKey, tx)
return fn(ctxTx)
}, opts)
}
+6 -9
View File
@@ -9,7 +9,6 @@ import (
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
type Router struct {
@@ -54,10 +53,9 @@ type Router struct {
expenseHandler *handler.ExpenseHandler
authMiddleware *middleware.AuthMiddleware
customerAuthMiddleware *middleware.CustomerAuthMiddleware
redisClient *redis.Client
}
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, redisClient *redis.Client) *Router {
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl) *Router {
return &Router{
config: cfg,
@@ -101,7 +99,6 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
selfOrderHandler: selfOrderHandler,
productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator),
expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator),
redisClient: redisClient,
}
}
@@ -277,19 +274,19 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
orders.GET("", r.orderHandler.ListOrders)
orders.GET("/:id", r.orderHandler.GetOrderByID)
orders.POST("", r.orderHandler.CreateOrder)
orders.POST("/:id/add-items", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.AddToOrder)
orders.POST("/:id/add-items", r.orderHandler.AddToOrder)
orders.PUT("/:id", r.orderHandler.UpdateOrder)
orders.PUT("/:id/customer", r.orderHandler.SetOrderCustomer)
orders.POST("/void", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.VoidOrder)
orders.POST("/:id/refund", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.RefundOrder)
orders.POST("/void", r.orderHandler.VoidOrder)
orders.POST("/:id/refund", r.orderHandler.RefundOrder)
orders.POST("/split-bill", r.orderHandler.SplitBill)
}
payments := protected.Group("/payments")
payments.Use(r.authMiddleware.RequireAdminOrManager())
{
payments.POST("", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.CreatePayment)
payments.POST("/:id/refund", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.RefundPayment)
payments.POST("", r.orderHandler.CreatePayment)
payments.POST("/:id/refund", r.orderHandler.RefundPayment)
}
paymentMethods := protected.Group("/payment-methods")
+40 -73
View File
@@ -4,11 +4,11 @@ import (
"context"
"fmt"
"log"
"math"
"sync"
"time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
@@ -17,38 +17,32 @@ import (
)
const (
defaultCheckInterval = 5 * time.Minute
defaultCheckInterval = 1 * time.Hour
OmsetMillionRupiah = 1_000_000.0
)
// OmsetMilestoneScheduler periodically checks each outlet's omset for the
// current calendar day and sends a notification every time it crosses a new
// multiple of OmsetMillionRupiah (1 jt, 2 jt, 3 jt, …).
// OmsetMilestoneScheduler periodically checks each organization's total omset
// and sends a notification to owner/admin users when a milestone is reached.
//
// The notified state is keyed by "outletID:YYYY-MM-DD:N" so each multiple is
// only notified once per day. State resets naturally on the next day (new key).
// NOTE: state is in-memory; a server restart within the same day may re-send
// notifications for already-crossed milestones.
// NOTE: Milestone tracking is in-memory; notifications may re-trigger after a restart.
// For persistent tracking, persist the notified state in the database.
type OmsetMilestoneScheduler struct {
orgRepo *repository.OrganizationRepositoryImpl
outletRepo *repository.OutletRepositoryImpl
userRepo *repository.UserRepositoryImpl
notificationProc processor.NotificationProcessor
mu sync.Mutex
notified map[string]bool // "outletID:YYYY-MM-DD:N" -> already notified
notified map[string]bool // "orgID:milestone" -> already notified
stopCh chan struct{}
}
func NewOmsetMilestoneScheduler(
orgRepo *repository.OrganizationRepositoryImpl,
outletRepo *repository.OutletRepositoryImpl,
userRepo *repository.UserRepositoryImpl,
notificationProc processor.NotificationProcessor,
) *OmsetMilestoneScheduler {
return &OmsetMilestoneScheduler{
orgRepo: orgRepo,
outletRepo: outletRepo,
userRepo: userRepo,
notificationProc: notificationProc,
notified: make(map[string]bool),
@@ -63,8 +57,8 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
}
go func() {
// Perform an initial check immediately on startup.
s.checkAllOutlets()
// Perform an initial check immediately.
s.checkAllOrganizations()
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -72,7 +66,7 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
for {
select {
case <-ticker.C:
s.checkAllOutlets()
s.checkAllOrganizations()
case <-s.stopCh:
log.Println("Omset milestone scheduler stopped")
return
@@ -80,7 +74,7 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
}
}()
log.Printf("Omset milestone scheduler started (interval: %s)", interval)
log.Println("Omset milestone scheduler started")
}
// Stop signals the scheduler to stop.
@@ -88,7 +82,7 @@ func (s *OmsetMilestoneScheduler) Stop() {
close(s.stopCh)
}
func (s *OmsetMilestoneScheduler) checkAllOutlets() {
func (s *OmsetMilestoneScheduler) checkAllOrganizations() {
ctx := context.Background()
orgs, _, err := s.orgRepo.List(ctx, nil, 1000, 0)
@@ -98,38 +92,25 @@ func (s *OmsetMilestoneScheduler) checkAllOutlets() {
}
for _, org := range orgs {
outlets, err := s.outletRepo.GetByOrganizationID(ctx, org.ID)
if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to list outlets for org %s: %v", org.ID, err)
continue
}
for _, outlet := range outlets {
if !outlet.IsActive {
continue
}
s.checkOutlet(ctx, org.ID, outlet.ID, outlet.Name)
}
s.checkOrganization(ctx, org)
}
}
func (s *OmsetMilestoneScheduler) checkOutlet(ctx context.Context, organizationID, outletID uuid.UUID, outletName string) {
todayOmset, err := s.outletRepo.GetTodayOmset(ctx, outletID)
func (s *OmsetMilestoneScheduler) checkOrganization(ctx context.Context, org *entities.Organization) {
totalOmset, err := s.orgRepo.GetTotalOmset(ctx, org.ID)
if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to get today's omset for outlet %s: %v", outletID, err)
log.Printf("OmsetMilestoneScheduler: failed to get total omset for org %s: %v", org.ID, err)
return
}
if todayOmset < OmsetMillionRupiah {
return
}
milestones := []float64{OmsetMillionRupiah}
// How many full multiples of 1 juta have been crossed today?
crossedMultiple := int(math.Floor(todayOmset / OmsetMillionRupiah))
today := time.Now().Format("2006-01-02")
for _, milestone := range milestones {
if totalOmset < milestone {
continue
}
for n := 1; n <= crossedMultiple; n++ {
key := fmt.Sprintf("%s:%s:%d", outletID.String(), today, n)
key := fmt.Sprintf("%s:%.0f", org.ID.String(), milestone)
s.mu.Lock()
if s.notified[key] {
@@ -139,31 +120,23 @@ func (s *OmsetMilestoneScheduler) checkOutlet(ctx context.Context, organizationI
s.notified[key] = true
s.mu.Unlock()
milestone := float64(n) * OmsetMillionRupiah
s.sendMilestoneNotification(ctx, organizationID, outletID, outletName, todayOmset, milestone, n)
s.sendMilestoneNotification(ctx, org, totalOmset, milestone)
}
}
func (s *OmsetMilestoneScheduler) sendMilestoneNotification(
ctx context.Context,
organizationID, outletID uuid.UUID,
outletName string,
todayOmset, milestone float64,
multiple int,
) {
// Fetch all users in the org, then filter to owner and manager only.
// These roles are not assigned to a specific outlet, so we query by org.
users, err := s.userRepo.GetByOrganizationID(ctx, organizationID)
func (s *OmsetMilestoneScheduler) sendMilestoneNotification(ctx context.Context, org *entities.Organization, totalOmset float64, milestone float64) {
users, err := s.userRepo.GetByOrganizationID(ctx, org.ID)
if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to get users for org %s: %v", organizationID, err)
log.Printf("OmsetMilestoneScheduler: failed to get users for org %s: %v", org.ID, err)
return
}
// Notify owner and admin users.
var receiverIDs []uuid.UUID
for _, u := range users {
role := string(u.Role)
if role == string(constants.RoleOwner) || role == string(constants.RoleManager) {
receiverIDs = append(receiverIDs, u.ID)
for _, user := range users {
roleStr := string(user.Role)
if roleStr == string(constants.RoleOwner) || roleStr == string(constants.RoleAdmin) {
receiverIDs = append(receiverIDs, user.ID)
}
}
@@ -171,34 +144,28 @@ func (s *OmsetMilestoneScheduler) sendMilestoneNotification(
return
}
title := fmt.Sprintf("🎉 Omset %s Hari Ini Mencapai Rp %.0f!", outletName, milestone)
body := fmt.Sprintf(
"Selamat! Omset outlet %s hari ini sudah menembus Rp %.0f (total hari ini: Rp %.0f). Terus semangat!",
outletName, milestone, todayOmset,
)
orgID := org.ID
title := "🎉 Selamat! Omset Telah Mencapai 1 Juta Rupiah"
body := fmt.Sprintf("Organisasi %s telah mencapai omset Rp %.0f. Terus tingkatkan prestasinya!", org.Name, totalOmset)
notifReq := &models.SendNotificationRequest{
Title: title,
Body: body,
Type: "milestone",
Category: "omset_milestone",
NotifiableType: "outlet",
NotifiableID: &outletID,
NotifiableType: "organization",
NotifiableID: &orgID,
ReceiverIDs: receiverIDs,
Data: map[string]interface{}{
"organization_id": organizationID.String(),
"outlet_id": outletID.String(),
"outlet_name": outletName,
"today_omset": todayOmset,
"organization_id": org.ID.String(),
"total_omset": totalOmset,
"milestone": milestone,
"multiple": multiple,
},
}
if _, err := s.notificationProc.Send(ctx, notifReq); err != nil {
log.Printf("OmsetMilestoneScheduler: failed to send notification for outlet %s: %v", outletID, err)
log.Printf("OmsetMilestoneScheduler: failed to send notification for org %s: %v", org.ID, err)
} else {
log.Printf("OmsetMilestoneScheduler: sent milestone x%d (Rp %.0f) for outlet %s (today omset: %.0f)",
multiple, milestone, outletName, todayOmset)
log.Printf("OmsetMilestoneScheduler: sent milestone notification to org %s (omset: %.0f)", org.ID, totalOmset)
}
}
+3 -19
View File
@@ -3,7 +3,6 @@ package service
import (
"apskel-pos-be/internal/appcontext"
"context"
"database/sql"
"fmt"
"time"
@@ -229,9 +228,7 @@ func (s *OrderServiceImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, re
var response *models.AddToOrderResponse
var ingredientTransactions []*contract.CreateOrderIngredientTransactionRequest
err := s.txManager.WithTransactionOptions(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
}, func(txCtx context.Context) error {
err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
addResp, err := s.orderProcessor.AddToOrder(txCtx, orderID, req)
if err != nil {
return fmt.Errorf("failed to add items to order: %w", err)
@@ -308,16 +305,8 @@ func (s *OrderServiceImpl) VoidOrder(ctx context.Context, req *models.VoidOrderR
return fmt.Errorf("invalid user ID")
}
err := s.txManager.WithTransactionOptions(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
}, func(txCtx context.Context) error {
if err := s.orderProcessor.VoidOrder(txCtx, req, voidedBy); err != nil {
return fmt.Errorf("failed to void order: %w", err)
}
return nil
})
if err != nil {
return err
if err := s.orderProcessor.VoidOrder(ctx, req, voidedBy); err != nil {
return fmt.Errorf("failed to void order: %w", err)
}
if err := s.handleTableReleaseOnVoid(ctx, req.OrderID); err != nil {
@@ -572,14 +561,9 @@ func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymen
return fmt.Errorf("payment item amount must be greater than zero for item %d", i+1)
}
fmt.Printf("[DEBUG] CreatePayment order_id=%s item[%d] order_item_id=%s amount=%.10f\n",
req.OrderID, i, item.OrderItemID, item.Amount)
totalItemAmount += item.Amount
}
fmt.Printf("[DEBUG] CreatePayment order_id=%s total_amount=%.10f sum_items=%.10f diff=%.10f\n",
req.OrderID, req.Amount, totalItemAmount, req.Amount-totalItemAmount)
if totalItemAmount != req.Amount {
return fmt.Errorf("sum of payment item amounts must equal total payment amount")
}
-6
View File
@@ -16,12 +16,6 @@ func HandleResponse(w http.ResponseWriter, r *http.Request, response *contract.R
} else {
responseError := response.GetErrors()[0]
statusCode = MapErrorCodeToHttpStatus(responseError.GetCode())
logger.FromContext(r.Context()).WithFields(map[string]interface{}{
"error_code": responseError.GetCode(),
"error_entity": responseError.GetEntity(),
"error_cause": responseError.GetCause(),
"status_code": statusCode,
}).Error(methodName)
}
WriteResponse(w, r, *response, statusCode, methodName)
}