From 117e3529aa0885b76c8a7e57b1f9dbd2f623041c Mon Sep 17 00:00:00 2001 From: efrilm Date: Sun, 4 Oct 2026 21:54:13 +0700 Subject: [PATCH] feat: update profit sharing --- docs/migrasi-profit-sharing.md | 289 ++++++++++++++++++ internal/constants/budget.go | 12 +- internal/contract/analytics_contract.go | 18 +- internal/contract/category_contract.go | 3 + internal/entities/category.go | 10 +- internal/mappers/category_mapper.go | 12 + internal/mappers/category_mapper_test.go | 34 +++ internal/middleware/auth_middleware.go | 2 +- internal/models/analytics.go | 18 +- internal/models/category.go | 4 + internal/processor/analytics_processor.go | 24 +- .../processor/analytics_processor_test.go | 7 +- internal/processor/budget_cutoff_test.go | 22 +- internal/processor/team.go | 17 +- internal/processor/team_test.go | 64 ++++ internal/repository/analytics_repository.go | 14 +- internal/router/router.go | 6 +- internal/service/report_service.go | 32 +- internal/transformer/analytics_transformer.go | 18 +- internal/transformer/category_transformer.go | 3 + internal/validator/category_validator.go | 2 +- .../000101_add_is_team_to_categories.down.sql | 1 + .../000101_add_is_team_to_categories.up.sql | 4 + 23 files changed, 521 insertions(+), 95 deletions(-) create mode 100644 docs/migrasi-profit-sharing.md create mode 100644 internal/mappers/category_mapper_test.go create mode 100644 internal/processor/team_test.go create mode 100644 migrations/000101_add_is_team_to_categories.down.sql create mode 100644 migrations/000101_add_is_team_to_categories.up.sql diff --git a/docs/migrasi-profit-sharing.md b/docs/migrasi-profit-sharing.md new file mode 100644 index 0000000..32a838c --- /dev/null +++ b/docs/migrasi-profit-sharing.md @@ -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": "", + "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=` 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": "", + "parent_category_name": "Drink", + "owner_fee_percent": 35, + "sdl": 700000, + "total_revenue": 2000000 + }, + { + "parent_category_id": "", + "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. diff --git a/internal/constants/budget.go b/internal/constants/budget.go index ac62e5a..06f625a 100644 --- a/internal/constants/budget.go +++ b/internal/constants/budget.go @@ -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 diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index d5dccfe..2b4e3e5 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -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 { diff --git a/internal/contract/category_contract.go b/internal/contract/category_contract.go index 322f3c1..83d1aa1 100644 --- a/internal/contract/category_contract.go +++ b/internal/contract/category_contract.go @@ -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"` diff --git a/internal/entities/category.go b/internal/entities/category.go index ca85c8f..53a2c86 100644 --- a/internal/entities/category.go +++ b/internal/entities/category.go @@ -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"` diff --git a/internal/mappers/category_mapper.go b/internal/mappers/category_mapper.go index 8130bc2..5b377ce 100644 --- a/internal/mappers/category_mapper.go +++ b/internal/mappers/category_mapper.go @@ -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 } diff --git a/internal/mappers/category_mapper_test.go b/internal/mappers/category_mapper_test.go new file mode 100644 index 0000000..3a096fc --- /dev/null +++ b/internal/mappers/category_mapper_test.go @@ -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: ¬Team, 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) + }) + } +} diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go index 9bfc7a8..a1fa52b 100644 --- a/internal/middleware/auth_middleware.go +++ b/internal/middleware/auth_middleware.go @@ -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 diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 0e23750..ca11f70 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -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 { diff --git a/internal/models/category.go b/internal/models/category.go index 7953863..7b47b45 100644 --- a/internal/models/category.go +++ b/internal/models/category.go @@ -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 diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 80d8bdd..804747f 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -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, diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index fe84ea4..aef6306 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -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) diff --git a/internal/processor/budget_cutoff_test.go b/internal/processor/budget_cutoff_test.go index 3feafa6..70033fd 100644 --- a/internal/processor/budget_cutoff_test.go +++ b/internal/processor/budget_cutoff_test.go @@ -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) diff --git a/internal/processor/team.go b/internal/processor/team.go index a38a533..04f7ee3 100644 --- a/internal/processor/team.go +++ b/internal/processor/team.go @@ -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 { diff --git a/internal/processor/team_test.go b/internal/processor/team_test.go new file mode 100644 index 0000000..c8781e1 --- /dev/null +++ b/internal/processor/team_test.go @@ -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") +} diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 7082b17..b788f9e 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -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). diff --git a/internal/router/router.go b/internal/router/router.go index 55bc014..2022cfd 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) diff --git a/internal/service/report_service.go b/internal/service/report_service.go index 8ba124f..afcfe5c 100644 --- a/internal/service/report_service.go +++ b/internal/service/report_service.go @@ -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 { diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 0166d2a..79327d0 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -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, diff --git a/internal/transformer/category_transformer.go b/internal/transformer/category_transformer.go index 1c6abfb..386cf12 100644 --- a/internal/transformer/category_transformer.go +++ b/internal/transformer/category_transformer.go @@ -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, diff --git a/internal/validator/category_validator.go b/internal/validator/category_validator.go index a390035..8895cd8 100644 --- a/internal/validator/category_validator.go +++ b/internal/validator/category_validator.go @@ -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 } diff --git a/migrations/000101_add_is_team_to_categories.down.sql b/migrations/000101_add_is_team_to_categories.down.sql new file mode 100644 index 0000000..1c85ac5 --- /dev/null +++ b/migrations/000101_add_is_team_to_categories.down.sql @@ -0,0 +1 @@ +ALTER TABLE categories DROP COLUMN is_team; diff --git a/migrations/000101_add_is_team_to_categories.up.sql b/migrations/000101_add_is_team_to_categories.up.sql new file mode 100644 index 0000000..c55290c --- /dev/null +++ b/migrations/000101_add_is_team_to_categories.up.sql @@ -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;