Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23633f8cb2 | ||
|
|
2d8aada8d6 | ||
|
|
14cdbeac21 | ||
|
|
5b162d0030 | ||
|
|
cfdc5cbb2a | ||
|
|
e2ae5a7ec4 |
@@ -1,111 +0,0 @@
|
|||||||
name: Deploy to K8s
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Debug - print Gitea Actions environment
|
|
||||||
run: |
|
|
||||||
echo "=== All environment variables ==="
|
|
||||||
env | sort
|
|
||||||
echo ""
|
|
||||||
echo "=== GITHUB_* / GITEA_* / ACTIONS_* vars ==="
|
|
||||||
env | grep -E '^(GITHUB|GITEA|ACTIONS)_' | sort
|
|
||||||
|
|
||||||
- name: Install kubectl
|
|
||||||
run: |
|
|
||||||
curl -sfLO "https://dl.k8s.io/release/$(curl -sfL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
|
||||||
install kubectl /usr/local/bin/
|
|
||||||
|
|
||||||
- name: Get Kanidm token from Vault
|
|
||||||
id: vault
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
|
|
||||||
VAULT_AUTH_RESPONSE=$(curl -f --request POST \
|
|
||||||
--data '{"role_id":"${{ secrets.VAULT_ROLE_ID }}","secret_id":"${{ secrets.VAULT_SECRET_ID }}"}' \
|
|
||||||
https://vault.hrajfrisbee.cz/v1/auth/approle/login)
|
|
||||||
|
|
||||||
echo "Vault auth response: $VAULT_AUTH_RESPONSE" >&2
|
|
||||||
VAULT_TOKEN=$(echo "$VAULT_AUTH_RESPONSE" | jq -r '.auth.client_token')
|
|
||||||
|
|
||||||
# Read the kanidm API token
|
|
||||||
SECRET_RESPONSE=$(curl -f \
|
|
||||||
-H "X-Vault-Token: ${VAULT_TOKEN}" \
|
|
||||||
https://vault.hrajfrisbee.cz/v1/secret/data/gitea/gitea-ci)
|
|
||||||
|
|
||||||
echo "Secret response: $SECRET_RESPONSE" >&2
|
|
||||||
API_TOKEN=$(echo "$SECRET_RESPONSE" | jq -r '.data.data.token')
|
|
||||||
|
|
||||||
echo "::add-mask::${API_TOKEN}"
|
|
||||||
echo "api_token=${API_TOKEN}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Exchange for K8s OIDC token via Kanidm
|
|
||||||
id: k8s
|
|
||||||
run: |
|
|
||||||
API_TOKEN="${{ steps.vault.outputs.api_token }}"
|
|
||||||
echo "api_token length: ${#API_TOKEN}" >&2
|
|
||||||
echo "api_token prefix (first 8 chars): ${API_TOKEN:0:8}..." >&2
|
|
||||||
|
|
||||||
HTTP_BODY=$(mktemp)
|
|
||||||
HTTP_STATUS=$(curl -sS -X POST "https://idm.home.hrajfrisbee.cz/oauth2/token" \
|
|
||||||
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
|
|
||||||
-d "client_id=k8s" \
|
|
||||||
-d "subject_token=${API_TOKEN}" \
|
|
||||||
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
|
|
||||||
-d "audience=k8s" \
|
|
||||||
-d "scope=openid groups" \
|
|
||||||
-o "$HTTP_BODY" -w "%{http_code}")
|
|
||||||
|
|
||||||
echo "HTTP status: $HTTP_STATUS" >&2
|
|
||||||
echo "Response body:" >&2
|
|
||||||
cat "$HTTP_BODY" >&2
|
|
||||||
|
|
||||||
RESPONSE=$(cat "$HTTP_BODY")
|
|
||||||
ID_TOKEN=$(echo "$RESPONSE" | jq -r '.id_token // empty')
|
|
||||||
|
|
||||||
if [ -z "$ID_TOKEN" ]; then
|
|
||||||
echo "::error::Kanidm token exchange failed (HTTP $HTTP_STATUS)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "::add-mask::${ID_TOKEN}"
|
|
||||||
echo "id_token=${ID_TOKEN}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
# Sanity check
|
|
||||||
# Warning - this is the part that is failing
|
|
||||||
# echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{sub, groups, exp}'
|
|
||||||
|
|
||||||
|
|
||||||
- name: Debug - print environment before kubectl
|
|
||||||
run: env | sort
|
|
||||||
|
|
||||||
- name: Configure kubectl & deploy
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.K8S_CA_CERT }}" > /tmp/ca.crt
|
|
||||||
|
|
||||||
kubectl config set-cluster mycluster \
|
|
||||||
--server=https://192.168.0.31:6443 \
|
|
||||||
--insecure-skip-tls-verify=true
|
|
||||||
# --certificate-authority=/tmp/ca.crt \
|
|
||||||
|
|
||||||
kubectl config set-credentials gitea-ci \
|
|
||||||
--token="${{ steps.k8s.outputs.id_token }}"
|
|
||||||
|
|
||||||
kubectl config set-context gitea-ci \
|
|
||||||
--cluster=mycluster --user=gitea-ci
|
|
||||||
|
|
||||||
kubectl config use-context gitea-ci
|
|
||||||
|
|
||||||
kubectl auth whoami
|
|
||||||
kubectl get ns
|
|
||||||
|
|
||||||
# your deploy here
|
|
||||||
# kubectl apply -f k8s/
|
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-10-08 11:53 CEST — feat(fees): adult rates for 2026-07 through 2026-12, default 600
|
||||||
|
|
||||||
|
- Adult default monthly rate (months not in the table): 700 → 600 CZK.
|
||||||
|
- 2026-07 and 2026-08: 600 → 0 CZK; 2026-09 through 2026-12 added at 600 CZK (September and October were previously billed at the old 700 default).
|
||||||
|
- The single-session fee (200 CZK) is now capped at the month's rate, so a 0-rate month is free for one session too.
|
||||||
|
- Mirrored in `go/internal/domain/fees/fees.go` and `legacy/scripts/attendance.py`; three new parity fixtures for `calculate_fee`.
|
||||||
|
|
||||||
|
## 2026-10-08 11:48 CEST — fix(ci): remove kubernetes-deploy workflow that leaked secrets to logs
|
||||||
|
|
||||||
|
- Deleted `.gitea/workflows/kubernetes-deploy.yaml`. It ran on every push to every branch, deployed nothing, and echoed the Vault login response, the Kanidm API token response and the OIDC token response to the job log.
|
||||||
|
- Deployment is handled by `gitops-update.yaml`. Repo secrets `VAULT_ROLE_ID`, `VAULT_SECRET_ID` and `K8S_CA_CERT` are now unused.
|
||||||
|
- Follow-up outside the repo: rotate the Vault AppRole secret and the Kanidm API token, and clear old job logs.
|
||||||
|
|
||||||
## 2026-10-08 11:34 CEST — fix(python): return months_to_show from /api/adults and /api/juniors
|
## 2026-10-08 11:34 CEST — fix(python): return months_to_show from /api/adults and /api/juniors
|
||||||
|
|
||||||
- Go already returned `months_to_show` in the JSON API; Python only passed it to its HTML templates. Backported the key so the API contract matches and `make parity` reports 3/3 routes.
|
- Go already returned `months_to_show` in the JSON API; Python only passed it to its HTML templates. Backported the key so the API contract matches and `make parity` reports 3/3 routes.
|
||||||
|
|||||||
@@ -126,13 +126,13 @@ Fio Bank API ──► bank sync ──► Google Sheets (payments
|
|||||||
### Member tiers
|
### Member tiers
|
||||||
|
|
||||||
Tiers are set in column B of the attendance sheet:
|
Tiers are set in column B of the attendance sheet:
|
||||||
- `A` — Adult, pays fees (per-month rate from `AdultFeeMonthlyRate` / `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
|
- `A` — Adult, pays fees (per-month rate from `AdultFeeMonthlyRate` / `ADULT_FEE_MONTHLY_RATE`, fallback 600 CZK for 2+ sessions; 200 CZK for exactly 1, capped at the month's rate)
|
||||||
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet
|
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet
|
||||||
- `X` — Excluded from junior fee calculation (coaches, etc.)
|
- `X` — Excluded from junior fee calculation (coaches, etc.)
|
||||||
|
|
||||||
### Fee calculation
|
### Fee calculation
|
||||||
|
|
||||||
- Adults: 0 sessions → 0, 1 session → 200 CZK, 2+ sessions → monthly rate (default 700 CZK)
|
- Adults: 0 sessions → 0, 1 session → 200 CZK (never more than the month's rate, so a 0-rate month is free), 2+ sessions → monthly rate (default 600 CZK)
|
||||||
- Juniors: 0 → 0, 1 → `"?"` (manual review required), 2+ → monthly rate (default 500 CZK)
|
- Juniors: 0 → 0, 1 → `"?"` (manual review required), 2+ → monthly rate (default 500 CZK)
|
||||||
- Per-member per-month overrides live in the `exceptions` tab of the payments sheet (columns: Name, Period YYYY-MM, Amount, Note). Exceptions are keyed by `(normalize(name), normalize(period))`.
|
- Per-member per-month overrides live in the `exceptions` tab of the payments sheet (columns: Name, Period YYYY-MM, Amount, Note). Exceptions are keyed by `(normalize(name), normalize(period))`.
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ fuj-management/
|
|||||||
│
|
│
|
||||||
├── .gitea/workflows/
|
├── .gitea/workflows/
|
||||||
│ ├── build.yaml # CI: build + push Docker image
|
│ ├── build.yaml # CI: build + push Docker image
|
||||||
│ └── kubernetes-deploy.yaml # CD: deploy to K8s cluster
|
│ └── gitops-update.yaml # CD: open image-update PR in home-kubernetes
|
||||||
│
|
│
|
||||||
├── .secret/ # (gitignored) API credentials
|
├── .secret/ # (gitignored) API credentials
|
||||||
├── docs/ # Project documentation
|
├── docs/ # Project documentation
|
||||||
|
|||||||
@@ -129,32 +129,27 @@ The project uses two Gitea Actions workflows:
|
|||||||
|
|
||||||
**Tag resolution**: Uses the git tag name. For manual dispatch, uses the provided input.
|
**Tag resolution**: Uses the git tag name. For manual dispatch, uses the provided input.
|
||||||
|
|
||||||
#### 2. Deploy to Kubernetes (`kubernetes-deploy.yaml`)
|
#### 2. GitOps image update (`gitops-update.yaml`)
|
||||||
|
|
||||||
**Triggers**:
|
**Triggers**:
|
||||||
- Push to any branch
|
- Completion of a successful "Build and Push" run
|
||||||
- Manual dispatch
|
- Manual dispatch (with `tag`, `dry_run` and `uh_cli_version` inputs)
|
||||||
|
|
||||||
**Steps**:
|
**Steps**:
|
||||||
1. Checkout code
|
1. Install `tea` and `uh-cli`
|
||||||
2. Install kubectl
|
2. Resolve the image tag of the Go image that was just built
|
||||||
3. Retrieve Kanidm token from HashiCorp Vault:
|
3. Open a PR in `kacerr/home-kubernetes` bumping the `fuj-management` Deployment (namespace `fuj`) to that image
|
||||||
- Authenticate to Vault via AppRole (`VAULT_ROLE_ID` / `VAULT_SECRET_ID`)
|
|
||||||
- Fetch API token from `secret/data/gitea/gitea-ci`
|
The cluster picks the change up once that PR is merged. CI does not talk to the cluster directly.
|
||||||
4. Exchange API token for K8s OIDC token via Kanidm:
|
|
||||||
- POST to `https://idm.home.hrajfrisbee.cz/oauth2/token`
|
> The earlier `kubernetes-deploy.yaml` workflow (Vault → Kanidm → `kubectl`) was removed on 2026-10-08. It never deployed anything and printed credentials to the job log.
|
||||||
- Token exchange using `urn:ietf:params:oauth:grant-type:token-exchange`
|
|
||||||
5. Configure kubectl with the OIDC token
|
|
||||||
6. Run `kubectl auth whoami` and `kubectl get ns` (deploy commands are commented out — WIP)
|
|
||||||
|
|
||||||
**Required secrets**:
|
**Required secrets**:
|
||||||
|
|
||||||
| Secret | Purpose |
|
| Secret | Purpose |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `REGISTRY_TOKEN` | Docker registry authentication |
|
| `REGISTRY_TOKEN` | Docker registry authentication |
|
||||||
| `VAULT_ROLE_ID` | HashiCorp Vault AppRole role ID |
|
| `GITOPS_TOKEN` | Gitea token with write and PR access to `home-kubernetes` |
|
||||||
| `VAULT_SECRET_ID` | HashiCorp Vault AppRole secret ID |
|
|
||||||
| `K8S_CA_CERT` | Kubernetes cluster CA certificate |
|
|
||||||
|
|
||||||
### Infrastructure Topology
|
### Infrastructure Topology
|
||||||
|
|
||||||
@@ -164,8 +159,7 @@ Gitea (git push / tag)
|
|||||||
├── build.yaml → Docker Build → Gitea Container Registry
|
├── build.yaml → Docker Build → Gitea Container Registry
|
||||||
│ (gitea.home.hrajfrisbee.cz)
|
│ (gitea.home.hrajfrisbee.cz)
|
||||||
│
|
│
|
||||||
└── kubernetes-deploy.yaml → Vault → Kanidm → K8s Cluster
|
└── gitops-update.yaml → PR in home-kubernetes → K8s Cluster
|
||||||
(192.168.0.31:6443)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This is a self-hosted infrastructure stack:
|
This is a self-hosted infrastructure stack:
|
||||||
|
|||||||
@@ -30,14 +30,21 @@ before new feature work starts. Companion to the Go rewrite tracker,
|
|||||||
|
|
||||||
## Fix first
|
## Fix first
|
||||||
|
|
||||||
- [ ] **T1** Remove the secret leak in `.gitea/workflows/kubernetes-deploy.yaml`.
|
- [x] **T1** Remove the secret leak in `.gitea/workflows/kubernetes-deploy.yaml`.
|
||||||
|
Workflow deleted on 2026-10-08. Still open for the owner: rotate the
|
||||||
|
credentials and remove the now-unused repo secrets (see below).
|
||||||
- It runs on every push to every branch and deploys nothing.
|
- It runs on every push to every branch and deploys nothing.
|
||||||
- It echoes the raw Vault login response, the Kanidm token response and the
|
- It echoes the raw Vault login response, the Kanidm token response and the
|
||||||
OIDC token response body to the log, and dumps the environment twice.
|
OIDC token response body to the log, and dumps the environment twice.
|
||||||
- It uses `--insecure-skip-tls-verify=true`.
|
- It uses `--insecure-skip-tls-verify=true`.
|
||||||
- Delete or disable the workflow. Rotate the Vault AppRole secret and the
|
- Delete or disable the workflow. Rotate the Vault AppRole secret and the
|
||||||
Kanidm API token if anyone else can read the CI logs.
|
Kanidm API token if anyone else can read the CI logs.
|
||||||
- [ ] **T2** Confirm the fee rates for 2026-09 onward.
|
- After deletion the repo secrets `VAULT_ROLE_ID`, `VAULT_SECRET_ID` and
|
||||||
|
`K8S_CA_CERT` are unused and can be removed. Old job logs still contain
|
||||||
|
the leaked tokens until they are deleted or expire.
|
||||||
|
- [x] **T2** Confirm the fee rates for 2026-09 onward. Done 2026-10-08:
|
||||||
|
adult default 600, 2026-07/08 at 0, 2026-09 to 2026-12 at 600. Junior
|
||||||
|
rates were not changed.
|
||||||
- `ADULT_FEE_MONTHLY_RATE` ends at 2026-08 in `scripts/attendance.py` and
|
- `ADULT_FEE_MONTHLY_RATE` ends at 2026-08 in `scripts/attendance.py` and
|
||||||
`go/internal/domain/fees/fees.go`.
|
`go/internal/domain/fees/fees.go`.
|
||||||
- September and October 2026 are billed at the defaults: 700 CZK adults,
|
- September and October 2026 are billed at the defaults: 700 CZK adults,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
package fees
|
package fees
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AdultFeeDefault = 700 // CZK fallback for 2+ practices when month not in AdultFeeMonthlyRate
|
AdultFeeDefault = 600 // CZK fallback for 2+ practices when month not in AdultFeeMonthlyRate
|
||||||
AdultFeeSingle = 200 // CZK for exactly 1 practice
|
AdultFeeSingle = 200 // CZK for exactly 1 practice
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,24 +11,27 @@ const (
|
|||||||
var AdultFeeMonthlyRate = map[string]int{
|
var AdultFeeMonthlyRate = map[string]int{
|
||||||
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
|
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
|
||||||
"2026-01": 750, "2026-02": 750, "2026-03": 350,
|
"2026-01": 750, "2026-02": 750, "2026-03": 350,
|
||||||
"2026-04": 700, "2026-05": 450, "2026-06": 600, "2026-07": 600, "2026-08": 600,
|
"2026-04": 700, "2026-05": 450, "2026-06": 600, "2026-07": 0, "2026-08": 0,
|
||||||
|
"2026-09": 600, "2026-10": 600, "2026-11": 600, "2026-12": 600,
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateFee returns the adult fee in CZK for attendanceCount practices in
|
// CalculateFee returns the adult fee in CZK for attendanceCount practices in
|
||||||
// the given monthKey (format "YYYY-MM").
|
// the given monthKey (format "YYYY-MM").
|
||||||
//
|
//
|
||||||
// 0 practices → 0
|
// 0 practices → 0
|
||||||
// 1 practice → AdultFeeSingle (200)
|
// 1 practice → AdultFeeSingle (200), capped at the month's rate so a
|
||||||
|
// 0-rate month is free for everyone
|
||||||
// 2+ → AdultFeeMonthlyRate[monthKey] or AdultFeeDefault
|
// 2+ → AdultFeeMonthlyRate[monthKey] or AdultFeeDefault
|
||||||
func CalculateFee(attendanceCount int, monthKey string) int {
|
func CalculateFee(attendanceCount int, monthKey string) int {
|
||||||
if attendanceCount == 0 {
|
if attendanceCount == 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
rate, ok := AdultFeeMonthlyRate[monthKey]
|
||||||
|
if !ok {
|
||||||
|
rate = AdultFeeDefault
|
||||||
|
}
|
||||||
if attendanceCount == 1 {
|
if attendanceCount == 1 {
|
||||||
return AdultFeeSingle
|
return min(AdultFeeSingle, rate)
|
||||||
}
|
}
|
||||||
if rate, ok := AdultFeeMonthlyRate[monthKey]; ok {
|
|
||||||
return rate
|
return rate
|
||||||
}
|
|
||||||
return AdultFeeDefault
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ func TestCalculateFee(t *testing.T) {
|
|||||||
// Zero attendance always returns 0.
|
// Zero attendance always returns 0.
|
||||||
{"zero short-circuits", 0, "2026-05", 0},
|
{"zero short-circuits", 0, "2026-05", 0},
|
||||||
{"zero empty month", 0, "", 0},
|
{"zero empty month", 0, "", 0},
|
||||||
// Single practice returns AdultFeeSingle regardless of month.
|
// Single practice returns AdultFeeSingle, capped at the month's rate.
|
||||||
{"single practice", 1, "2026-05", AdultFeeSingle},
|
{"single practice", 1, "2026-05", AdultFeeSingle},
|
||||||
{"single ignores monthKey", 1, "unknown", AdultFeeSingle},
|
{"single unknown month", 1, "unknown", AdultFeeSingle},
|
||||||
|
{"single in free month is free", 1, "2026-07", 0},
|
||||||
|
{"two in free month is free", 2, "2026-08", 0},
|
||||||
// Two+ practices for a configured month: must use the map value, not the default.
|
// Two+ practices for a configured month: must use the map value, not the default.
|
||||||
// Expected values are read from AdultFeeMonthlyRate so this test stays correct
|
// Expected values are read from AdultFeeMonthlyRate so this test stays correct
|
||||||
// when rates are updated — the assertion verifies dispatch logic, not rate values.
|
// when rates are updated — the assertion verifies dispatch logic, not rate values.
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"case": "one_session_free_july",
|
||||||
|
"func": "scripts.attendance.calculate_fee",
|
||||||
|
"captured_at": "2026-10-08",
|
||||||
|
"input": {
|
||||||
|
"attendance_count": 1,
|
||||||
|
"month_key": "2026-07"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"fee": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"case": "two_sessions_default_fallback",
|
"case": "two_sessions_default_fallback",
|
||||||
"func": "scripts.attendance.calculate_fee",
|
"func": "scripts.attendance.calculate_fee",
|
||||||
"captured_at": "2026-05-06",
|
"captured_at": "2026-10-08",
|
||||||
"input": {
|
"input": {
|
||||||
"attendance_count": 2,
|
"attendance_count": 2,
|
||||||
"month_key": "2099-01"
|
"month_key": "2099-01"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"fee": 700
|
"fee": 600
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"case": "two_sessions_free_july",
|
||||||
|
"func": "scripts.attendance.calculate_fee",
|
||||||
|
"captured_at": "2026-10-08",
|
||||||
|
"input": {
|
||||||
|
"attendance_count": 2,
|
||||||
|
"month_key": "2026-07"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"fee": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"case": "two_sessions_october",
|
||||||
|
"func": "scripts.attendance.calculate_fee",
|
||||||
|
"captured_at": "2026-10-08",
|
||||||
|
"input": {
|
||||||
|
"attendance_count": 2,
|
||||||
|
"month_key": "2026-10"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"fee": 600
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,6 +115,15 @@ SEEDS[("calculate_fee", "two_sessions_reduced_march")] = {
|
|||||||
SEEDS[("calculate_fee", "two_sessions_default_fallback")] = {
|
SEEDS[("calculate_fee", "two_sessions_default_fallback")] = {
|
||||||
"attendance_count": 2, "month_key": "2099-01"
|
"attendance_count": 2, "month_key": "2099-01"
|
||||||
}
|
}
|
||||||
|
SEEDS[("calculate_fee", "two_sessions_free_july")] = {
|
||||||
|
"attendance_count": 2, "month_key": "2026-07"
|
||||||
|
}
|
||||||
|
SEEDS[("calculate_fee", "one_session_free_july")] = {
|
||||||
|
"attendance_count": 1, "month_key": "2026-07"
|
||||||
|
}
|
||||||
|
SEEDS[("calculate_fee", "two_sessions_october")] = {
|
||||||
|
"attendance_count": 2, "month_key": "2026-10"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# --- calculate_junior_fee ---
|
# --- calculate_junior_fee ---
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from config import ATTENDANCE_SHEET_ID as SHEET_ID, JUNIOR_SHEET_GID
|
|||||||
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0"
|
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0"
|
||||||
JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}"
|
JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}"
|
||||||
|
|
||||||
ADULT_FEE_DEFAULT = 700 # CZK, for 2+ practices in a month
|
ADULT_FEE_DEFAULT = 600 # CZK, for 2+ practices in a month
|
||||||
ADULT_FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
|
ADULT_FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
|
||||||
ADULT_FEE_MONTHLY_RATE = {
|
ADULT_FEE_MONTHLY_RATE = {
|
||||||
"2025-09": 750,
|
"2025-09": 750,
|
||||||
@@ -23,8 +23,12 @@ ADULT_FEE_MONTHLY_RATE = {
|
|||||||
"2026-04": 700,
|
"2026-04": 700,
|
||||||
"2026-05": 450,
|
"2026-05": 450,
|
||||||
"2026-06": 600,
|
"2026-06": 600,
|
||||||
"2026-07": 600,
|
"2026-07": 0,
|
||||||
"2026-08": 600,
|
"2026-08": 0,
|
||||||
|
"2026-09": 600,
|
||||||
|
"2026-10": 600,
|
||||||
|
"2026-11": 600,
|
||||||
|
"2026-12": 600,
|
||||||
}
|
}
|
||||||
|
|
||||||
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
|
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
|
||||||
@@ -91,12 +95,13 @@ def group_by_month(dates: list[tuple[int, datetime]], merged_months: dict[str, s
|
|||||||
|
|
||||||
|
|
||||||
def calculate_fee(attendance_count: int, month_key: str) -> int:
|
def calculate_fee(attendance_count: int, month_key: str) -> int:
|
||||||
"""Apply fee rules: 0 → 0, 1 → 200, 2+ → configured rate (default 750)."""
|
"""Apply fee rules: 0 → 0, 1 → 200 (capped at the month's rate), 2+ → configured rate (default 600)."""
|
||||||
if attendance_count == 0:
|
if attendance_count == 0:
|
||||||
return 0
|
return 0
|
||||||
|
rate = ADULT_FEE_MONTHLY_RATE.get(month_key, ADULT_FEE_DEFAULT)
|
||||||
if attendance_count == 1:
|
if attendance_count == 1:
|
||||||
return ADULT_FEE_SINGLE
|
return min(ADULT_FEE_SINGLE, rate)
|
||||||
return ADULT_FEE_MONTHLY_RATE.get(month_key, ADULT_FEE_DEFAULT)
|
return rate
|
||||||
|
|
||||||
|
|
||||||
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
|
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
|
||||||
|
|||||||
Reference in New Issue
Block a user