feat: update profit sharing

This commit is contained in:
efrilm
2026-10-04 21:54:13 +07:00
parent b59b7b4ddd
commit 117e3529aa
23 changed files with 521 additions and 95 deletions
+289
View File
@@ -0,0 +1,289 @@
# Migrasi profit sharing
4 Oktober 2026
## Ringkasan
Ada tiga perubahan di backend:
1. **Parent category bisa ditandai bukan team.** Kategori punya field baru `is_team`. Parent category dengan `is_team: false` tidak bisa dipilih sebagai team di purchase order dan cash advance, dan tidak ikut laporan profit sharing.
2. **Endpoint laporan pindah path.** `/api/v1/analytics/parent-categories` menjadi `/api/v1/analytics/profit-sharing`.
3. **Pembagian revenue tinggal dua porsi.** Porsi purchase (60%) dihapus. Revenue sekarang dibagi ke owner (SDL) dan team, dengan porsi team = 100% − fee owner.
Nomor 2 dan 3 adalah breaking change. Setelah backend baru dirilis, client yang masih memanggil path lama mendapat 404, dan `limit_purchase` serta `percentages.purchase` tidak ada lagi di response. Karena itu client harus diupdate lebih dulu, lihat [Urutan rilis](#database-dan-urutan-rilis).
Yang perlu bertindak, di dashboard maupun app mobile, mana pun yang punya layarnya:
- **Form kategori**: tambah toggle team untuk parent category.
- **Laporan profit sharing**: ganti path, hapus porsi purchase, tampilkan dua porsi.
- **Form purchase order dan cash advance**: picker team tidak perlu diubah, tapi form edit perlu menyesuaikan, lihat [Purchase order dan cash advance](#purchase-order-dan-cash-advance).
## Kategori: field `is_team`
### Response
Semua response kategori membawa `is_team` (boolean, tidak pernah `null`): `POST /api/v1/categories`, `PUT /api/v1/categories/:id`, `GET /api/v1/categories`, dan `GET /api/v1/categories/:id`.
```json
{
"id": "<uuid>",
"name": "Merchandise",
"parent_id": null,
"owner_fee_percent": null,
"is_team": false
}
```
### Request
`POST /api/v1/categories` dan `PUT /api/v1/categories/:id` menerima `is_team`.
```json
{
"name": "Merchandise",
"is_team": false
}
```
| Request | `is_team` yang dikirim | Hasil |
| --- | --- | --- |
| Create | tidak dikirim | `true` |
| Create | `false` | `false` |
| Update | tidak dikirim atau `null` | tidak berubah |
| Update | `true` atau `false` | diganti |
Update yang hanya berisi `is_team` diterima.
Aturan nilainya:
- `is_team` hanya berpengaruh di parent category, yaitu kategori dengan `parent_id: null`. Di sub-category nilainya disimpan tapi tidak dipakai, jadi tampilkan toggle hanya untuk parent category.
- Semua kategori yang sudah ada sebelum rilis bernilai `true`. Tidak ada yang berubah sampai admin mematikannya.
- Flag dibaca saat request, bukan saat transaksi. Kalau parent category dimatikan, penjualannya di periode lampau juga hilang dari laporan profit sharing. Kalau dinyalakan lagi, semuanya muncul kembali.
### Efek `is_team: false`
| Endpoint | Efek |
| --- | --- |
| `GET /api/v1/purchase-orders/teams`, `GET /api/v1/cash-advances/teams` | Kategori tidak muncul. Pusat tetap ada. |
| Create dan update purchase order dan cash advance | `team_scope: "category"` dengan `team_category_id` kategori ini ditolak. |
| `GET /api/v1/analytics/profit-sharing` | Kategori tidak muncul di `data`, dan revenue-nya tidak dihitung di `budget`. |
| `GET /api/v1/analytics/profit-sharing/:parent_category_id` | Ditolak. |
Data yang sudah ada tidak diubah. Purchase order dan cash advance yang sudah tercatat ke kategori itu tetap menyimpan team-nya. Laporan purchasing (`GET /api/v1/analytics/purchasing`) masih menampilkannya di `team_data`, dan filter `team=<category_id>` tetap bisa dipakai.
Laporan lain yang tidak menyaring `is_team`, misalnya `GET /api/v1/analytics/categories`, tetap menampilkan penjualan kategori itu.
## Purchase order dan cash advance
Picker team sudah mengambil dari `GET /api/v1/purchase-orders/teams` dan `GET /api/v1/cash-advances/teams`, jadi kategori non-team otomatis tidak muncul tanpa perubahan di client.
Yang perlu diubah ada di form edit. Purchase order atau cash advance lama bisa tercatat ke kategori yang sekarang sudah non-team. Kalau form edit mengirim ulang `team_scope` dan `team_category_id` yang sama, request ditolak, walaupun user tidak mengubah team-nya.
1. Kirim `team_scope` dan `team_category_id` hanya kalau user mengganti team. Kalau `team_scope` tidak dikirim, team yang tersimpan tidak berubah.
2. Kalau team yang tersimpan tidak ada di daftar `/teams`, tampilkan namanya dari field `team` di response apa adanya, dan jangan memilihkan team lain secara otomatis.
Contoh error saat team ditolak, dengan HTTP status 500:
```json
{
"success": false,
"data": null,
"errors": [
{
"code": "900",
"entity": "purchase_order_service",
"cause": "category Merchandise is not a team"
}
]
}
```
Untuk cash advance, `entity` bernilai `cash_advance_service`. Cocokkan dengan teks `is not a team` di `cause` kalau perlu menampilkan pesan khusus.
## Laporan profit sharing
### Path baru
| Lama | Baru |
| --- | --- |
| `GET /api/v1/analytics/parent-categories` | `GET /api/v1/analytics/profit-sharing` |
| `GET /api/v1/analytics/parent-categories/:parent_category_id` | `GET /api/v1/analytics/profit-sharing/:parent_category_id` |
Query parameter dan role tidak berubah:
- `date_from` dan `date_to` wajib, dengan format `DD-MM-YYYY`, misalnya `28-09-2026`.
- `outlet_id` opsional.
- Hanya bisa diakses superadmin, admin, manager, owner, dan purchasing.
Path lama sudah tidak ada dan mengembalikan 404.
### Pembagian revenue
Revenue tiap parent category yang team dibagi dua:
- **SDL (fee owner)** = revenue × `owner_fee_percent` / 100. Default-nya 20%, dan bisa diganti per parent category lewat `owner_fee_percent` di kategori.
- **Team** = revenue − SDL.
Contoh dengan tiga parent category dalam satu minggu:
| Parent category | `is_team` | Fee owner | Revenue | SDL | Team |
| --- | --- | --- | --- | --- | --- |
| Food | `true` | 20% (default) | 1.000.000 | 200.000 | 800.000 |
| Drink | `true` | 35% | 2.000.000 | 700.000 | 1.300.000 |
| Merchandise | `false` | - | 500.000 | tidak dihitung | tidak dihitung |
| **Budget minggu ini** | | | **3.000.000** | **900.000** | **2.100.000** |
### Perubahan field
| Field | Sebelum | Sesudah |
| --- | --- | --- |
| `data[]` | semua parent category | hanya parent category team |
| `budget.percentages.purchase` | `60` | dihapus |
| `budget.percentages.owner` | `20`, atau fee parent itu di endpoint detail | tidak berubah |
| `budget.percentages.team` | `20` | `100 − owner`: `80` di list, `100 − fee parent` di detail |
| `limit_purchase` di `budget.total`, `budget.weekly[]`, `budget.monthly[]` | 60% revenue | dihapus |
| `limit_team` di tempat yang sama | 20% revenue | `revenue − sdl` |
| `revenue` dan `sdl` di `budget` | semua parent category | hanya parent category team |
Di endpoint list, `budget.percentages` selalu berisi default `20` dan `80`, walaupun ada parent dengan fee berbeda. Angka `sdl` dan `limit_team` dihitung per parent dengan fee masing-masing, jadi `limit_team / revenue` bisa tidak persis 80%. Tampilkan angka rupiah dari response, jangan dihitung ulang dari persentase.
Baris di `data[]` membawa `sdl` tapi tidak membawa porsi team. Kalau porsi team per parent perlu ditampilkan, hitung dari `total_revenue − sdl`.
Contoh response list, dipotong:
```json
{
"success": true,
"data": {
"date_from": "2026-09-28T00:00:00+07:00",
"date_to": "2026-10-04T23:59:59.999999999+07:00",
"data": [
{
"parent_category_id": "<uuid>",
"parent_category_name": "Drink",
"owner_fee_percent": 35,
"sdl": 700000,
"total_revenue": 2000000
},
{
"parent_category_id": "<uuid>",
"parent_category_name": "Food",
"owner_fee_percent": 20,
"sdl": 200000,
"total_revenue": 1000000
}
],
"budget": {
"percentages": { "owner": 20, "team": 80 },
"cut_off_from": "2026-09-28T00:00:00+07:00",
"cut_off_to": "2026-10-04T23:59:59.999999999+07:00",
"total": {
"period_start": "2026-09-28T00:00:00+07:00",
"period_end": "2026-10-04T23:59:59.999999999+07:00",
"revenue": 3000000,
"order_count": 4,
"sdl": 900000,
"limit_team": 2100000
},
"weekly": [
{
"period_start": "2026-09-28T00:00:00+07:00",
"period_end": "2026-10-04T23:59:59.999999999+07:00",
"revenue": 3000000,
"order_count": 4,
"sdl": 900000,
"limit_team": 2100000
}
],
"monthly": [
{
"month": "2026-09",
"week_count": 1,
"period_start": "2026-09-28T00:00:00+07:00",
"period_end": "2026-10-04T23:59:59.999999999+07:00",
"revenue": 3000000,
"order_count": 4,
"sdl": 900000,
"limit_team": 2100000
}
]
}
},
"errors": null
}
```
Di endpoint detail, `budget` bentuknya sama, tapi `percentages` memakai fee parent itu, misalnya `{ "owner": 35, "team": 65 }` untuk Drink.
### Detail kategori non-team
Detail untuk parent category non-team ditolak dengan HTTP status 500:
```json
{
"success": false,
"data": null,
"errors": [
{
"code": "internal_error",
"entity": "AnalyticsHandler::GetParentCategoryAnalyticsDetail",
"cause": "failed to get parent category analytics detail: failed to get parent category analytics detail: category Merchandise is not a team"
}
]
}
```
Ini bisa terjadi kalau user membuka link lama atau bookmark ke parent yang baru dimatikan. Cocokkan dengan teks `is not a team` di `cause`, lalu arahkan user kembali ke list.
## Migrasi client
### Laporan profit sharing
1. Ganti path ke `/api/v1/analytics/profit-sharing`. Selama backend lama masih jalan, path baru mengembalikan 404. Kalau dapat 404, panggil path lama `/api/v1/analytics/parent-categories`, supaya client baru bisa dirilis sebelum backend.
2. Hapus tampilan limit purchase dan persentase purchase. Jangan menganggap `limit_purchase` atau `percentages.purchase` selalu ada.
3. Tampilkan dua porsi dengan label "SDL / Fee owner" dan "Team", ambil angkanya dari `sdl` dan `limit_team`.
4. Kalau detail ditolak dengan `is not a team`, arahkan kembali ke list.
Selama fallback ke backend lama, `limit_team` masih berisi 20% revenue. Angkanya baru jadi `revenue − sdl` setelah backend baru dirilis.
### Form kategori
1. Tambah toggle `is_team` untuk parent category, misalnya berlabel "Ikut profit sharing (team)". Untuk kategori baru, toggle menyala secara default.
2. Saat membuka form edit, isi toggle dari `is_team`. Selama backend lama masih jalan, field ini tidak ada di response, jadi anggap `true`.
3. Saat admin mematikan toggle, tampilkan konfirmasi bahwa kategori itu tidak akan muncul di pilihan team dan di laporan profit sharing, termasuk untuk periode lampau.
4. Di daftar kategori, beri penanda untuk parent category dengan `is_team: false`.
Backend lama mengabaikan `is_team` di request, jadi toggle bisa dirilis lebih dulu, tapi belum berpengaruh sampai backend baru jalan. Pengecualiannya update yang hanya berisi `is_team`: backend lama menolaknya dengan error code `303` dan pesan `at least one field must be provided for update`. Selama masa transisi, kirim `is_team` bersama field form lainnya.
## Database dan urutan rilis
Migration `000101_add_is_team_to_categories` menambah kolom `categories.is_team` (`BOOLEAN NOT NULL DEFAULT TRUE`). Semua kategori yang ada otomatis bernilai `true`. Migration ini hanya menambah kolom, jadi backend lama tetap jalan normal setelahnya.
Urutan rilis:
1. Rilis client baru: path profit sharing dengan fallback ke path lama, tanpa porsi purchase, dan dengan toggle `is_team`. Untuk app mobile, pastikan versi baru sudah dipakai sebagian besar user sebelum langkah 3, karena versi lama yang memanggil `/parent-categories` mendapat 404 setelah itu.
2. Jalankan migration `000101`.
3. Deploy backend baru.
4. Hapus fallback path lama di client.
5. Admin mematikan `is_team` di parent category yang bukan team.
Rollback: deploy backend lama, lalu jalankan down migration yang menghapus kolom `is_team`. Client dengan fallback tetap jalan di backend lama. Nilai `is_team` yang sudah diatur admin hilang saat kolom dihapus.
## Checklist
- [ ] Client baru (fallback path, tanpa porsi purchase, toggle `is_team`) dirilis
- [ ] Migration `000101` dan backend baru dirilis di staging
- [ ] Uji: parent category yang dimatikan hilang dari `/purchase-orders/teams` dan `/cash-advances/teams`
- [ ] Uji: purchase order dan cash advance baru dengan kategori itu ditolak, dan edit purchase order lama tanpa mengganti team tetap berhasil
- [ ] Uji: kategori itu hilang dari `/analytics/profit-sharing`, dan detailnya ditolak
- [ ] Uji: `limit_team = revenue − sdl`, dan `sdl` mengikuti fee masing-masing parent
- [ ] Migration `000101` dan backend baru dirilis di production
- [ ] Fallback path lama di client dihapus
## FAQ
**Kenapa porsi team jadi 100% − fee owner, bukan tetap 20%?** Porsi purchase sudah tidak ada, jadi seluruh revenue dibagi dua. Owner mengambil fee-nya, dan sisanya untuk team. Kalau fee owner sebuah parent dinaikkan, porsi team parent itu turun sebesar yang sama.
**Apakah sub-category bisa dijadikan non-team sendiri?** Tidak. Team dan profit sharing dihitung per parent category, jadi semua sub-category ikut status parent-nya.
**Bagaimana dengan kategori top-level yang tidak punya sub-category?** Kategori itu tetap parent category, jadi `is_team` berlaku untuknya.
+4 -8
View File
@@ -1,10 +1,6 @@
package constants
// Budget allocation of revenue used by the parent category cut-off report.
// The three shares are expected to add up to 100. BudgetLimitOwnerPercent is only the
// default: a parent category can override it with categories.owner_fee_percent.
const (
BudgetLimitPurchasePercent = 60.0
BudgetLimitOwnerPercent = 20.0
BudgetLimitTeamPercent = 20.0
)
// Revenue split used by the profit sharing report: the owner takes its fee as SDL and
// the team takes the rest. BudgetLimitOwnerPercent is only the default: a parent
// category can override it with categories.owner_fee_percent.
const BudgetLimitOwnerPercent = 20.0
+8 -10
View File
@@ -372,19 +372,17 @@ type BudgetCutOff struct {
}
type BudgetPercentages struct {
Purchase float64 `json:"purchase"`
Owner float64 `json:"owner"`
Team float64 `json:"team"`
Owner float64 `json:"owner"`
Team float64 `json:"team"`
}
type BudgetPeriod struct {
PeriodStart time.Time `json:"period_start"`
PeriodEnd time.Time `json:"period_end"`
Revenue float64 `json:"revenue"`
OrderCount int64 `json:"order_count"`
LimitPurchase float64 `json:"limit_purchase"`
SDL float64 `json:"sdl"`
LimitTeam float64 `json:"limit_team"`
PeriodStart time.Time `json:"period_start"`
PeriodEnd time.Time `json:"period_end"`
Revenue float64 `json:"revenue"`
OrderCount int64 `json:"order_count"`
SDL float64 `json:"sdl"`
LimitTeam float64 `json:"limit_team"`
}
type BudgetMonthPeriod struct {
+3
View File
@@ -16,6 +16,7 @@ type CreateCategoryRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Order *int `json:"order,omitempty"`
OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"`
IsTeam *bool `json:"is_team,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
@@ -27,6 +28,7 @@ type UpdateCategoryRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Order *int `json:"order,omitempty"`
OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"`
IsTeam *bool `json:"is_team,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
// Set when the field is sent as null (or "" for parent_id), which asks for the
@@ -99,6 +101,7 @@ type CategoryResponse struct {
BusinessType string `json:"business_type"`
Order int `json:"order"`
OwnerFeePercent *float64 `json:"owner_fee_percent"`
IsTeam bool `json:"is_team"`
Metadata map[string]interface{} `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+7 -3
View File
@@ -42,9 +42,13 @@ type Category struct {
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
// OwnerFeePercent overrides the owner's share in the parent category budget report; nil uses the default
OwnerFeePercent *float64 `gorm:"type:numeric(5,2)" json:"owner_fee_percent"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
OwnerFeePercent *float64 `gorm:"type:numeric(5,2)" json:"owner_fee_percent"`
// IsTeam marks a parent category as a team in the revenue split. It has no gorm
// default on purpose: GORM would turn an explicit false into the default on insert,
// so callers creating a category set it themselves.
IsTeam bool `gorm:"not null" json:"is_team"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"`
+12
View File
@@ -58,6 +58,12 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
metadata["image_url"] = *req.ImageURL
}
// A category is a team unless the request says otherwise
isTeam := true
if req.IsTeam != nil {
isTeam = *req.IsTeam
}
return &entities.Category{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
@@ -66,6 +72,7 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
Description: req.Description,
Order: req.Order,
OwnerFeePercent: req.OwnerFeePercent,
IsTeam: isTeam,
BusinessType: "restaurant",
Metadata: metadata,
}
@@ -105,6 +112,7 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
ImageURL: imageURL,
Order: entity.Order,
OwnerFeePercent: entity.OwnerFeePercent,
IsTeam: entity.IsTeam,
IsActive: true,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
@@ -142,6 +150,10 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
entity.OwnerFeePercent = req.OwnerFeePercent
}
if req.IsTeam != nil {
entity.IsTeam = *req.IsTeam
}
if req.OutletID != nil {
entity.OutletID = req.OutletID
}
+34
View File
@@ -0,0 +1,34 @@
package mappers
import (
"testing"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestCreateCategoryRequestToEntityDefaultsToTeam(t *testing.T) {
notTeam := false
tests := []struct {
name string
isTeam *bool
want bool
}{
{name: "omitted is a team", isTeam: nil, want: true},
{name: "false is not a team", isTeam: &notTeam, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entity := CreateCategoryRequestToEntity(&models.CreateCategoryRequest{
OrganizationID: uuid.New(),
Name: "Merchandise",
IsTeam: tt.isTeam,
})
require.Equal(t, tt.want, entity.IsTeam)
})
}
}
+1 -1
View File
@@ -82,7 +82,7 @@ func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
}
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
}
// RequireLoyaltyManager guards what moves or prices EnakPoint and EnakCoin: loyalty
+8 -10
View File
@@ -412,19 +412,17 @@ type BudgetCutOff struct {
}
type BudgetPercentages struct {
Purchase float64 `json:"purchase"`
Owner float64 `json:"owner"`
Team float64 `json:"team"`
Owner float64 `json:"owner"`
Team float64 `json:"team"`
}
type BudgetPeriod struct {
PeriodStart time.Time `json:"period_start"`
PeriodEnd time.Time `json:"period_end"`
Revenue float64 `json:"revenue"`
OrderCount int64 `json:"order_count"`
LimitPurchase float64 `json:"limit_purchase"`
SDL float64 `json:"sdl"`
LimitTeam float64 `json:"limit_team"`
PeriodStart time.Time `json:"period_start"`
PeriodEnd time.Time `json:"period_end"`
Revenue float64 `json:"revenue"`
OrderCount int64 `json:"order_count"`
SDL float64 `json:"sdl"`
LimitTeam float64 `json:"limit_team"`
}
type BudgetMonthPeriod struct {
+4
View File
@@ -28,6 +28,8 @@ type CreateCategoryRequest struct {
ImageURL *string `validate:"omitempty,url"`
Order int `validate:"min=0"`
OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"`
// IsTeam defaults to true when left out
IsTeam *bool
}
type UpdateCategoryRequest struct {
@@ -38,6 +40,7 @@ type UpdateCategoryRequest struct {
ParentID *uuid.UUID
Order *int `validate:"omitempty,min=0"`
OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"`
IsTeam *bool
IsActive *bool
// Clear flags remove the value; a nil pointer above only means "leave unchanged"
@@ -56,6 +59,7 @@ type CategoryResponse struct {
ImageURL *string
Order int
OwnerFeePercent *float64
IsTeam bool
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
+12 -12
View File
@@ -545,6 +545,7 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
// The block covers a single parent category, so it reports that category's own fee
if detail.Summary != nil {
budget.Percentages.Owner = detail.Summary.OwnerFeePercent
budget.Percentages.Team = 100 - detail.Summary.OwnerFeePercent
}
return &models.ParentCategoryAnalyticsDetailResponse{
@@ -597,17 +598,17 @@ func endOfWeek(t time.Time) time.Time {
return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Nanosecond)
}
// newBudgetPeriod splits a period's revenue into the spending limits. The owner limit
// is passed in because a parent category can carry its own owner fee percent.
// newBudgetPeriod splits a period's revenue between the owner and the team. The owner
// share is passed in because a parent category can carry its own owner fee percent,
// and the team takes whatever the owner does not.
func newBudgetPeriod(start, end time.Time, revenue, sdl float64, orderCount int64) models.BudgetPeriod {
return models.BudgetPeriod{
PeriodStart: start,
PeriodEnd: end,
Revenue: revenue,
OrderCount: orderCount,
LimitPurchase: revenue * constants.BudgetLimitPurchasePercent / 100,
SDL: sdl,
LimitTeam: revenue * constants.BudgetLimitTeamPercent / 100,
PeriodStart: start,
PeriodEnd: end,
Revenue: revenue,
OrderCount: orderCount,
SDL: sdl,
LimitTeam: revenue - sdl,
}
}
@@ -621,9 +622,8 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza
budget := models.BudgetCutOff{
Percentages: models.BudgetPercentages{
Purchase: constants.BudgetLimitPurchasePercent,
Owner: constants.BudgetLimitOwnerPercent,
Team: constants.BudgetLimitTeamPercent,
Owner: constants.BudgetLimitOwnerPercent,
Team: 100 - constants.BudgetLimitOwnerPercent,
},
CutOffFrom: cutOffFrom,
CutOffTo: cutOffTo,
@@ -607,7 +607,7 @@ func TestPaymentMethodAnalytics_EnakPointIsNotCashIn(t *testing.T) {
}
// A parent category with its own owner fee percent moves the owner limit away from the
// default share, while purchase and team stay on the default split of revenue.
// default share, and the team limit shrinks by the same amount.
func TestAnalyticsProcessorParentCategoryUsesOwnerFeePercent(t *testing.T) {
monday := time.Date(2026, 9, 28, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
@@ -636,8 +636,9 @@ func TestAnalyticsProcessorParentCategoryUsesOwnerFeePercent(t *testing.T) {
require.Len(t, result.Budget.Weekly, 1)
require.Equal(t, float64(900), result.Budget.Weekly[0].SDL)
require.Equal(t, float64(1800), result.Budget.Weekly[0].LimitPurchase)
require.Equal(t, float64(600), result.Budget.Weekly[0].LimitTeam)
require.Equal(t, float64(2100), result.Budget.Weekly[0].LimitTeam)
require.Equal(t, float64(20), result.Budget.Percentages.Owner)
require.Equal(t, float64(80), result.Budget.Percentages.Team)
require.Len(t, result.Budget.Monthly, 1)
require.Equal(t, float64(900), result.Budget.Monthly[0].SDL)
require.Equal(t, float64(900), result.Budget.Total.SDL)
+11 -11
View File
@@ -93,18 +93,17 @@ func TestBuildBudgetCutOffAppliesLimits(t *testing.T) {
require.NoError(t, err)
require.Len(t, budget.Weekly, 1)
// 60 / 20 of the week's revenue; the owner limit is whatever the repository
// summed from each parent category's own fee percent
// The owner limit is whatever the repository summed from each parent category's
// own fee percent, and the team takes the rest of the week's revenue
week := budget.Weekly[0]
require.Equal(t, float64(6_000_000), week.LimitPurchase)
require.Equal(t, float64(2_500_000), week.SDL)
require.Equal(t, float64(2_000_000), week.LimitTeam)
require.Equal(t, float64(7_500_000), week.LimitTeam)
require.Equal(t, int64(120), week.OrderCount)
// Totals mirror the single week
require.Equal(t, week.Revenue, budget.Total.Revenue)
require.Equal(t, week.LimitPurchase, budget.Total.LimitPurchase)
require.Equal(t, week.SDL, budget.Total.SDL)
require.Equal(t, week.LimitTeam, budget.Total.LimitTeam)
}
func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
@@ -112,8 +111,8 @@ func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
{WeekStart: first, Revenue: 10_000_000, OrderCount: 100},
{WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60},
{WeekStart: first, Revenue: 10_000_000, OrderCount: 100, SDL: 2_000_000},
{WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60, SDL: 1_000_000},
}}
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
@@ -127,9 +126,10 @@ func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
require.Equal(t, float64(15_000_000), month.Revenue)
require.Equal(t, int64(160), month.OrderCount)
// The month limit is the accumulation of its weeks
require.Equal(t, float64(9_000_000), month.LimitPurchase)
require.Equal(t, budget.Weekly[0].LimitPurchase+budget.Weekly[1].LimitPurchase, month.LimitPurchase)
// The month limits are the accumulation of its weeks
require.Equal(t, float64(3_000_000), month.SDL)
require.Equal(t, float64(12_000_000), month.LimitTeam)
require.Equal(t, budget.Weekly[0].LimitTeam+budget.Weekly[1].LimitTeam, month.LimitTeam)
}
func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
@@ -147,7 +147,7 @@ func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
require.Len(t, budget.Weekly, 3)
require.Zero(t, budget.Weekly[0].Revenue)
require.Zero(t, budget.Weekly[0].LimitPurchase)
require.Zero(t, budget.Weekly[0].LimitTeam)
require.Zero(t, budget.Weekly[1].Revenue)
require.Equal(t, float64(4_000_000), budget.Weekly[2].Revenue)
require.Equal(t, float64(4_000_000), budget.Total.Revenue)
+12 -5
View File
@@ -11,12 +11,13 @@ import (
"github.com/google/uuid"
)
// Teams are the parent product categories, plus Pusat for spending that belongs to
// no single team. Both purchase orders and cash advances are charged to one, so the rules
// for picking and storing a team live here rather than in either processor.
// Teams are the parent product categories flagged is_team, plus Pusat for spending
// that belongs to no single team. Both purchase orders and cash advances are charged to
// one, so the rules for picking and storing a team live here rather than in either
// processor.
// listTeams returns the teams money can be charged to: the parent categories of the
// outlet in scope, followed by Pusat. Pusat has no category row, so it is appended
// listTeams returns the teams money can be charged to: the team parent categories of
// the outlet in scope, followed by Pusat. Pusat has no category row, so it is appended
// here rather than read from the database.
func listTeams(ctx context.Context, categoryRepo CategoryRepository, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) {
categories, err := categoryRepo.ListParentCategories(ctx, organizationID, outletID)
@@ -26,6 +27,9 @@ func listTeams(ctx context.Context, categoryRepo CategoryRepository, organizatio
teams := make([]models.PurchaseTeam, 0, len(categories)+1)
for _, category := range categories {
if !category.IsTeam {
continue
}
categoryID := category.ID
teams = append(teams, models.PurchaseTeam{
Scope: constants.PurchaseTeamScopeCategory,
@@ -75,6 +79,9 @@ func resolveTeamSelection(ctx context.Context, categoryRepo CategoryRepository,
if category.ParentID != nil {
return nil, nil, fmt.Errorf("team must be a parent category")
}
if !category.IsTeam {
return nil, nil, fmt.Errorf("category %s is not a team", category.Name)
}
// Categories without an outlet are shared, so only an outlet-specific
// category has to match the outlet the record is booked against.
if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID {
+64
View File
@@ -0,0 +1,64 @@
package processor
import (
"context"
"errors"
"testing"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
// teamCategoryRepositoryStub serves the two lookups the team rules make. The embedded
// interface is left nil, so any other call panics and shows up in the test.
type teamCategoryRepositoryStub struct {
CategoryRepository
categories []*entities.Category
}
func (s *teamCategoryRepositoryStub) ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error) {
return s.categories, nil
}
func (s *teamCategoryRepositoryStub) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
for _, category := range s.categories {
if category.ID == id {
return category, nil
}
}
return nil, errors.New("record not found")
}
func TestListTeamsSkipsParentCategoriesThatAreNotTeams(t *testing.T) {
organizationID := uuid.New()
food := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Food", IsTeam: true}
merch := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Merchandise", IsTeam: false}
repo := &teamCategoryRepositoryStub{categories: []*entities.Category{food, merch}}
result, err := listTeams(context.Background(), repo, organizationID, nil)
require.NoError(t, err)
require.Len(t, result.Teams, 2)
require.Equal(t, "Food", result.Teams[0].Name)
require.Equal(t, &food.ID, result.Teams[0].CategoryID)
require.Equal(t, constants.PurchaseTeamCentralName, result.Teams[1].Name)
}
func TestResolveTeamSelectionRejectsParentCategoryThatIsNotATeam(t *testing.T) {
organizationID := uuid.New()
food := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Food", IsTeam: true}
merch := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Merchandise", IsTeam: false}
repo := &teamCategoryRepositoryStub{categories: []*entities.Category{food, merch}}
scope := constants.PurchaseTeamScopeCategory
resolvedScope, resolvedID, err := resolveTeamSelection(context.Background(), repo, organizationID, nil, &scope, &food.ID)
require.NoError(t, err)
require.Equal(t, constants.PurchaseTeamScopeCategory, *resolvedScope)
require.Equal(t, food.ID, *resolvedID)
_, _, err = resolveTeamSelection(context.Background(), repo, organizationID, nil, &scope, &merch.ID)
require.EqualError(t, err, "category Merchandise is not a team")
}
+12 -2
View File
@@ -614,6 +614,8 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
Joins("JOIN categories c ON p.category_id = c.id").
// Categories without a parent roll up to themselves, so top-level categories still appear
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
// Profit sharing only covers the parent categories that are teams
Where("pc.is_team = ?", true).
Joins("JOIN orders o ON oi.order_id = o.id").
Joins("LEFT JOIN (SELECT pr.product_id, SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost) as hpp_per_unit FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id GROUP BY pr.product_id) shpp ON shpp.product_id = p.id").
Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id",
@@ -665,6 +667,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
Joins("JOIN categories c ON p.category_id = c.id").
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
Joins("JOIN orders o ON oi.order_id = o.id").
Where("pc.is_team = ?", true).
Where("o.organization_id = ?", organizationID).
Where("o.is_void = ?", false).
Where("o.is_refund = ?", false).
@@ -739,11 +742,12 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
var parent struct {
ID uuid.UUID
Name string
IsTeam bool
OwnerFeePercent float64
}
if err := r.db.WithContext(ctx).
Table("categories").
Select("id, name, COALESCE(owner_fee_percent, ?) as owner_fee_percent", constants.BudgetLimitOwnerPercent).
Select("id, name, is_team, COALESCE(owner_fee_percent, ?) as owner_fee_percent", constants.BudgetLimitOwnerPercent).
Where("id = ? AND organization_id = ?", parentCategoryID, organizationID).
Scan(&parent).Error; err != nil {
return nil, err
@@ -751,6 +755,9 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
if parent.ID == uuid.Nil {
return nil, fmt.Errorf("category not found")
}
if !parent.IsTeam {
return nil, fmt.Errorf("category %s is not a team", parent.Name)
}
detail := &entities.ParentCategoryAnalyticsDetail{
ParentCategoryID: parent.ID,
@@ -849,7 +856,8 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
// GetBudgetCutOffWeekly buckets revenue and cost of goods sold into Monday-to-Sunday
// weeks. DATE_TRUNC('week') is ISO, so the buckets start on Monday, and the connection
// runs with TimeZone=Asia/Jakarta so the boundaries land on local midnight.
// A nil parentCategoryID covers every category in scope.
// A nil parentCategoryID covers every team in scope; parent categories that are not a
// team stay out of the split.
func (r *AnalyticsRepositoryImpl) GetBudgetCutOffWeekly(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, parentCategoryID *uuid.UUID, cutOffFrom, cutOffTo time.Time) ([]*entities.BudgetCutOffWeek, error) {
var results []*entities.BudgetCutOffWeek
@@ -868,6 +876,8 @@ func (r *AnalyticsRepositoryImpl) GetBudgetCutOffWeekly(ctx context.Context, org
// The owner fee lives on the parent category; a category without a parent is its own group
Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)").
Joins("JOIN orders o ON oi.order_id = o.id").
// Only teams take part in the revenue split
Where("pc.is_team = ?", true).
Where("o.organization_id = ?", organizationID).
Where("o.is_void = ?", false).
Where("o.is_refund = ?", false).
+3 -3
View File
@@ -63,7 +63,7 @@ type Router struct {
customerDeviceHandler *handler.CustomerDeviceHandler
customerOutletHandler *handler.CustomerOutletHandler
customerOrderHandler *handler.CustomerOrderHandler
authMiddleware *middleware.AuthMiddleware
authMiddleware *middleware.AuthMiddleware
customerAuthMiddleware *middleware.CustomerAuthMiddleware
redisClient *redis.Client
}
@@ -374,8 +374,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics)
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
analytics.GET("/parent-categories", r.analyticsHandler.GetProductAnalyticsPerParentCategory)
analytics.GET("/parent-categories/:parent_category_id", r.analyticsHandler.GetParentCategoryAnalyticsDetail)
analytics.GET("/profit-sharing", r.analyticsHandler.GetProductAnalyticsPerParentCategory)
analytics.GET("/profit-sharing/:parent_category_id", r.analyticsHandler.GetParentCategoryAnalyticsDetail)
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics)
analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod)
+16 -16
View File
@@ -224,23 +224,23 @@ func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 {
// profitLossReportData holds data for the profit/loss PDF template
type profitLossReportData struct {
OrganizationName string
MonthName string
ReportDate string
ReportDateUpper string
TotalPenjualan string
TotalBiaya string
LabaRugi string
LabaRugiClass string
LabaRugiValueClass string
LabaRugiMtd string
LabaRugiMtdClass string
OrganizationName string
MonthName string
ReportDate string
ReportDateUpper string
TotalPenjualan string
TotalBiaya string
LabaRugi string
LabaRugiClass string
LabaRugiValueClass string
LabaRugiMtd string
LabaRugiMtdClass string
LabaRugiMtdValueClass string
MainSummary []profitLossSummaryRowView
PurchasingItems []profitLossPurchasingItem
PurchasingTotal string
GeneratedBy string
PrintTime string
MainSummary []profitLossSummaryRowView
PurchasingItems []profitLossPurchasingItem
PurchasingTotal string
GeneratedBy string
PrintTime string
}
type profitLossSummaryRowView struct {
+8 -10
View File
@@ -446,13 +446,12 @@ func parentCategoryTopProductModelToContract(product *models.ParentCategoryTopPr
// budgetPeriodModelToContract converts one budget period to contract
func budgetPeriodModelToContract(period models.BudgetPeriod) contract.BudgetPeriod {
return contract.BudgetPeriod{
PeriodStart: period.PeriodStart,
PeriodEnd: period.PeriodEnd,
Revenue: period.Revenue,
OrderCount: period.OrderCount,
LimitPurchase: period.LimitPurchase,
SDL: period.SDL,
LimitTeam: period.LimitTeam,
PeriodStart: period.PeriodStart,
PeriodEnd: period.PeriodEnd,
Revenue: period.Revenue,
OrderCount: period.OrderCount,
SDL: period.SDL,
LimitTeam: period.LimitTeam,
}
}
@@ -474,9 +473,8 @@ func BudgetCutOffModelToContract(budget models.BudgetCutOff) contract.BudgetCutO
return contract.BudgetCutOff{
Percentages: contract.BudgetPercentages{
Purchase: budget.Percentages.Purchase,
Owner: budget.Percentages.Owner,
Team: budget.Percentages.Team,
Owner: budget.Percentages.Owner,
Team: budget.Percentages.Team,
},
CutOffFrom: budget.CutOffFrom,
CutOffTo: budget.CutOffTo,
@@ -20,6 +20,7 @@ func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.C
ImageURL: nil,
Order: order,
OwnerFeePercent: req.OwnerFeePercent,
IsTeam: req.IsTeam,
}
}
@@ -32,6 +33,7 @@ func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.U
ParentID: req.ParentID,
Order: req.Order,
OwnerFeePercent: req.OwnerFeePercent,
IsTeam: req.IsTeam,
IsActive: nil,
ClearParentID: req.ClearParentID,
@@ -55,6 +57,7 @@ func CategoryModelResponseToResponse(cat *models.CategoryResponse) *contract.Cat
BusinessType: "restaurant",
Order: cat.Order,
OwnerFeePercent: cat.OwnerFeePercent,
IsTeam: cat.IsTeam,
Metadata: map[string]interface{}{},
CreatedAt: cat.CreatedAt,
UpdatedAt: cat.UpdatedAt,
+1 -1
View File
@@ -63,7 +63,7 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda
}
// At least one field should be provided for update
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil && req.OwnerFeePercent == nil && req.Order == nil && req.OutletID == nil && !req.ClearParentID && !req.ClearOwnerFeePercent {
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil && req.OwnerFeePercent == nil && req.IsTeam == nil && req.Order == nil && req.OutletID == nil && !req.ClearParentID && !req.ClearOwnerFeePercent {
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
}
@@ -0,0 +1 @@
ALTER TABLE categories DROP COLUMN is_team;
@@ -0,0 +1,4 @@
-- Whether a parent category is a team: it can be charged with purchases and cash
-- advances, and its revenue takes part in the parent category budget split. Every
-- existing category stays a team, since that is how parent categories were treated.
ALTER TABLE categories ADD COLUMN is_team BOOLEAN NOT NULL DEFAULT TRUE;