billing read-path a postpaid hosting pipeline navázané na entitu environment (f1). Stavební blok F2 dle ADR-026 a UC-10011 F2 scope.
- Staví na F1 (
environmenttabulka, lazy default „TalkIDE” per tenant). F1 musí být nasazena a backfill spuštěn před nasazením F2. - OpenCost poller (C.2,
RecordHostingCostBatchProcessor) rozšíří mapovánínamespace → tenantnanamespace → environmentpřidánímenvironment_idFK. - Postpaid akruální pipeline: raw
hosting_cost_events→ charged DEBIT řádek dohosting_credit_ledger→ inkrementacehosting_billing_account.accrued_charged_usd. Markup kontrakt z UC-10008 (PricingService.applyMarkup,NUMERIC(20,6),setScale(6, HALF_UP)). - Měsíční Stripe faktura: 1. kalendářního dne v měsíci (DP-5), jedna faktura per tenant,
per-environment
hosting_invoice_lineřádky (OD-6). - Trial (DP-1):
hosting_billing_account.trial_ends_atnastaven při 1. Publish naNOW() + trial_days;trial_days=0⇒trial_ends_at=null⇒ ihned metered. Trial je per tenant (OD-5), NE per prostředí. - Hosting spend cap (DP-2): nový
user_budget.hosting_spending_limit_usd(NEreuse UC-10005spending_limit_usd). Cap agregovaný přes všechna prostředí tenanta (OD-1). Při překročení: akruál zastaven + alert email. - F2 enforcement = SOFT ONLY (OD-2, LOCKED): dunning 3 retry (+0/+2/+5 dní)
→
PAST_DUE→ 7 dní grace →SUSPENDEDna záznamuhosting_billing_account. Žádné scale-to-zero, žádný namespace suspend, žádná infra akce. Hard enforcement přichází ve F4 (OD-2 LOCKED). todo-list.talkide.app(popelkam) ani žádný jiný živý pod nesmí být touto UC přerušen — guaranteed by design (soft-only enforcement).- Admin čtecí endpoint vrací AUTHENTICATION_FAILED 401 pro ne-admina (silent-probe, projektový standard — NE 403).
- Spring profil pro plánovanou úlohu:
@Profile("production")(NIKDY@Profile("prod")). - ShedLock
@SchedulerLockpro každou plánovanou úlohu (multi-pod idempotence). - Batch processing v samostatném
@Componentbean (obejití Spring AOP self-invocation).
Přehled subsystémů F2
F2 se skládá ze čtyř navzájem navazujících subsystémů:
| # | Subsystém | Trigger | Klíčové entity |
|---|---|---|---|
| A | C.2 OpenCost scrape + environment attribution | @Scheduled periodicky | hosting_cost_events, environment |
| B | Postpaid akruální reconciler | @Scheduled periodicky | hosting_credit_ledger, hosting_billing_account |
| C | Měsíční faktura + Stripe settle | @Scheduled 1. v měsíci | hosting_invoice, hosting_invoice_line |
| D | Dunning pipeline | @Scheduled denně + hosting_invoice webhook | hosting_billing_account status |
A. Sekvence — OpenCost scrape s environment attribution
sequenceDiagram
participant SCH as Scheduler
participant POLLER as HostingCostPollerBatch<br/>(ShedLock)
participant OC as OpenCostClient
participant ENV as EnvironmentRepository
participant DB
SCH->>+POLLER: @Scheduled (každých 15 min)
Note over POLLER: @SchedulerLock("hostingCostPoller", 20min)
POLLER->>+OC: fetchNamespaceAllocations(window = last 15 min)
OC-->>-POLLER: List[NamespaceAllocation] (ns, totalCost, cpuCost, ...)
loop pro každou NamespaceAllocation
POLLER->>+ENV: findByNamespaceRef(namespace)
alt environment nalezeno
ENV-->>POLLER: EnvironmentEntity (id, tenantId, ...)
POLLER->>DB: INSERT hosting_cost_events<br/>(tenant_id, environment_id, namespace, cost_usd, window_start, window_end, ...)<br/>ON CONFLICT (namespace, window_start, window_end) DO NOTHING
else namespace se nemapuje na žádné environment
Note over POLLER: WARN log + metrika "orphan_namespace"<br/>NEfakturovat — konzervativní non-charge (ADR-026 §6)
end
ENV-->>-POLLER: ok
end
POLLER-->>-SCH: done (persisted_count, skipped_count, orphan_count)
Idempotence — hosting_cost_events
UNIQUE constraint (namespace, window_start, window_end) na DB úrovni. Reconciler
při duplicitním INSERT dostane DataIntegrityViolationException — catch jako backstop
(ShedLock brání souběžnému spuštění, ale DB constraint je poslední obrana).
B. Sekvence — postpaid akruální reconciler
sequenceDiagram
participant SCH as Scheduler
participant REC as HostingAccrualReconcilerBatch<br/>(ShedLock)
participant PS as PricingService
participant DB
SCH->>+REC: @Scheduled (každých 15 min)
Note over REC: @SchedulerLock("hostingAccrualReconciler", 25min)
REC->>DB: SELECT * FROM hosting_cost_events<br/>WHERE reconciled = false<br/>ORDER BY recorded_at ASC<br/>LIMIT 500
loop pro každý nezúčtovaný hosting_cost_event
Note over REC: markup načten jednou před smyčkou:<br/>val markup = pricingService.getEffectiveMarkup()
REC->>+PS: applyMarkup(event.costUsd, markup.hostingMarkupPercent)
Note over PS: charged = raw * (1 + hostingMarkupPercent/100)<br/>setScale(6, HALF_UP)<br/>markup=0 ⇒ charged == raw (žádná regrese)
PS-->>-REC: BigDecimal (chargedAmountUsd)
REC->>DB: INSERT hosting_credit_ledger<br/>(tenant_id, environment_id, type=DEBIT,<br/>source=HOSTING_USAGE,<br/>raw_amount_usd, charged_amount_usd, markup_percent_snapshot,<br/>hosting_cost_event_id, billing_period_id)<br/>ON CONFLICT (hosting_cost_event_id) WHERE source='HOSTING_USAGE' DO NOTHING
REC->>DB: UPDATE hosting_billing_account<br/>SET accrued_charged_usd = accrued_charged_usd + chargedAmountUsd,<br/>updated_at = NOW()<br/>WHERE tenant_id = ?
alt accrued_charged_usd >= hosting_spending_limit_usd (cap překročen, DP-2)
REC->>DB: UPDATE hosting_billing_account SET status = 'CAP_REACHED' WHERE tenant_id = ?
REC->>REC: triggerCapAlertEmail(tenantId)
Note over REC: Akruál se pro tento tenant ZASTAVÍ<br/>dokud faktura nevynuluje accrued_charged_usd
end
REC->>DB: UPDATE hosting_cost_events SET reconciled = true WHERE id = ?
end
REC-->>-SCH: done
Hosting spend cap (DP-2)
user_budget.hosting_spending_limit_usd (NUMERIC(10,2) NULL; null = bez limitu).
Cap = agregovaný součet accrued_charged_usd přes všechna prostředí tenanta (OD-1) —
ukládáme běžící součet na hosting_billing_account.accrued_charged_usd. Při překročení:
DEBIT řádek se nezapisuje + alert email + hosting_billing_account.status = CAP_REACHED.
C. Sekvence — měsíční faktura + Stripe settle
sequenceDiagram
participant SCH as Scheduler
participant INV as HostingInvoiceBatch<br/>(ShedLock)
participant STR as StripeService
participant DB
SCH->>+INV: @Scheduled (1. v měsíci 02:00 UTC)
Note over INV: @SchedulerLock("hostingMonthlyInvoice", 2h)
INV->>DB: SELECT tenant_id FROM hosting_billing_account<br/>WHERE status IN ('ACTIVE','PAST_DUE')<br/>AND (trial_ends_at IS NULL OR trial_ends_at < NOW())<br/>AND current_period_end <= NOW()
loop pro každého tenanta mimo trial
INV->>DB: SELECT environment_id, SUM(charged_amount_usd) AS line_total<br/>FROM hosting_credit_ledger<br/>WHERE tenant_id=? AND type='DEBIT'<br/>AND billing_period_id IS NULL<br/>GROUP BY environment_id
alt Σ DEBIT = 0 (nic nenaběhlo)
Note over INV: Faktura se negeneruje (nulová částka)
else Σ DEBIT > 0
INV->>DB: INSERT hosting_invoice<br/>(tenant_id, period_start, period_end,<br/>total_charged_usd, status=DRAFT, attempt_count=0)
INV->>DB: INSERT hosting_invoice_line per environment_id<br/>(invoice_id, environment_id, charged_amount_usd,<br/>environment_name_snapshot)
INV->>+STR: createAndConfirmPaymentIntent<br/>(amount, currency, customer, payment_method,<br/>metadata: {fund=HOSTING, invoice_id=?})
alt payment_intent.succeeded (sync)
STR-->>INV: PaymentIntent (id, status=succeeded)
INV->>DB: UPDATE hosting_invoice SET status=OPEN,<br/>stripe_payment_intent_id=?
Note over INV: Webhook payment_intent.succeeded (async)<br/>→ UC-10006 handler → CREDIT INVOICE_SETTLEMENT<br/>→ accrued_charged_usd reset, period shift
else payment_intent failed
STR-->>-INV: PaymentIntent (id, status=requires_payment_method)
INV->>DB: UPDATE hosting_invoice<br/>SET status=FAILED, attempt_count=1,<br/>next_retry_at=NOW()+2d
INV->>DB: UPDATE hosting_billing_account SET status=PAST_DUE
Note over INV: Dunning pipeline převezme retry (Subsystém D)
end
end
end
INV-->>-SCH: done
UC-10006 webhook rozšíření — HOSTING fund
Existující StripeWebhookController rozšíří payment_intent.succeeded handler
o větev metadata.fund == "HOSTING":
payment_intent.succeeded, metadata.fund=HOSTING:
1. Najdi hosting_invoice dle metadata.invoice_id
2. INSERT hosting_credit_ledger (type=CREDIT, source=INVOICE_SETTLEMENT,
charged_amount_usd = invoice.total_charged_usd,
billing_period_id = invoice.id)
3. UPDATE hosting_invoice SET status=PAID, settled_at=NOW()
4. UPDATE hosting_billing_account:
accrued_charged_usd = 0,
current_period_start = NOW(),
current_period_end = NEXT 1st OF MONTH,
status = ACTIVE (reset z PAST_DUE/CAP_REACHED)
D. Sekvence — dunning pipeline
sequenceDiagram
participant SCH as Scheduler
participant DUNN as HostingDunningBatch<br/>(ShedLock)
participant MAIL as EmailService (Mailgun)
participant DB
SCH->>+DUNN: @Scheduled denně 06:00 UTC
Note over DUNN: @SchedulerLock("hostingDunning", 30min)
DUNN->>DB: SELECT hi.*, hba.* FROM hosting_invoice hi<br/>JOIN hosting_billing_account hba ON hba.tenant_id=hi.tenant_id<br/>WHERE hi.status='FAILED'<br/>AND hi.next_retry_at <= NOW()<br/>AND hi.attempt_count < 3
loop pro každou nezaplacenou fakturu k retry
DUNN->>+MAIL: sendDunningEmail(tenant, invoice, attempt_count)
MAIL-->>-DUNN: ok
DUNN->>DB: re-confirm PaymentIntent (Stripe API)
alt payment succeeded
Note over DUNN: webhook payment_intent.succeeded zpracuje UC-10006
else payment failed again
alt attempt_count == 1
DUNN->>DB: UPDATE hosting_invoice SET attempt_count=2, next_retry_at=NOW()+5d
else attempt_count == 2 (3. retry)
DUNN->>DB: UPDATE hosting_invoice SET attempt_count=3, next_retry_at=NULL,<br/>status=FAILED (final)
DUNN->>DB: UPDATE hosting_billing_account SET status=PAST_DUE
DUNN->>+MAIL: sendPastDueEmail(tenant, invoice)
MAIL-->>-DUNN: ok
end
end
end
DUNN->>DB: SELECT * FROM hosting_billing_account<br/>WHERE status='PAST_DUE'<br/>AND past_due_since < NOW()-7d
loop pro každý tenant v grace po 7 dnech
DUNN->>DB: UPDATE hosting_billing_account SET status='SUSPENDED'
Note over DUNN: SOFT ONLY — žádná infra akce.<br/>Status=SUSPENDED je jen DB záznam.<br/>Hard enforcement = F4.
DUNN->>+MAIL: sendSuspendedEmail(tenant)
MAIL-->>-DUNN: ok
end
DUNN-->>-SCH: done
Dunning rozvrh (DP-3)
| Pokus | Čas od uzávěrky | Akce |
|---|---|---|
| 1 (okamžitý) | +0 dní | Stripe confirm + dunning email „faktura nezaplacena” |
| 2 | +2 dní | Retry + dunning email |
| 3 | +5 dní | Retry + dunning email |
| — | Po 3 neúspěšných pokusech | Status → PAST_DUE + e-mail |
| — | PAST_DUE + 7 dní | Status → SUSPENDED (SOFT — jen DB záznam) |
E. API — FE billing breakdown (GET estimate)
GET /api/v1/billing/hosting/estimate
Autentizovaný user (tenant kontext povinný). Vrátí aktuálně naběhlý akruál + non-binding projected end-of-month s per-environment rozpadem (DP-6, OD-6).
200 OK HostingEstimateResponse:
{
"currentPeriodStart": "2026-05-01T00:00:00Z",
"currentPeriodEnd": "2026-06-01T00:00:00Z",
"nextInvoiceDate": "2026-06-01",
"status": "ACTIVE",
"trialEndsAt": null,
"accruedChargedUsd": "12.340000",
"projectedMonthlyUsd": "25.600000",
"hostingSpendingLimitUsd": "100.000000",
"disclaimer": "Projected amount is an estimate, not a binding invoice.",
"environments": [
{
"environmentId": 1,
"environmentName": "TalkIDE",
"environmentSlug": "talkide",
"accruedChargedUsd": "12.340000",
"projectedChargedUsd": "25.600000"
}
]
}
401 Unauthorized ErrorResponse (neautentizovaný nebo chybí tenant kontext):
{
"code": "AUTHENTICATION_FAILED",
"message": "Authentication required"
}
Výpočet projectedMonthlyUsd
Lineární extrapolace z accrued_charged_usd dle uplynulé části měsíce:
daysElapsed = dayOfMonth - 1 (day 1 = 0 dní uplynulo, nová perioda)
daysInMonth = daysInMonth(currentPeriodStart)
projected = accrued_charged_usd * daysInMonth / max(daysElapsed, 1)
Aplikovat BigDecimal.valueOf, setScale(6, HALF_UP). Výsledek je informativní,
explicitně označený disclaimerem v odpovědi.
F. API — admin report endpoint
GET /api/v1/admin/billing/hosting/report
Admin-only (ROLE_ADMIN, silent-probe). Vrátí přehled billing stavu přes všechny aktivní tenanty.
200 OK HostingBillingReportResponse:
{
"generatedAt": "2026-05-19T10:00:00Z",
"tenants": [
{
"tenantId": 42,
"tenantSlug": "popelkam",
"billingStatus": "ACTIVE",
"trialEndsAt": null,
"accruedChargedUsd": "12.340000",
"hostingSpendingLimitUsd": null,
"currentPeriodStart": "2026-05-01T00:00:00Z",
"currentPeriodEnd": "2026-06-01T00:00:00Z",
"lastInvoiceStatus": "PAID",
"environments": [
{
"environmentId": 1,
"environmentName": "TalkIDE",
"accruedChargedUsd": "12.340000"
}
]
}
]
}
401 Unauthorized ErrorResponse (ne-admin i neautentizovaný — silent-probe):
{
"code": "AUTHENTICATION_FAILED",
"message": "Authentication required"
}
Datový model — nové entity a vztahy (F2 delta)
erDiagram
ENVIRONMENT {
bigint id
bigint tenant_id
string kind
string name
string slug
string resource_mode
string status
boolean deletable
string namespace_ref
jsonb config
timestamp created_at
timestamp updated_at
}
HOSTING_COST_EVENTS {
bigint id
bigint tenant_id
bigint environment_id
string namespace
numeric cost_usd
numeric cpu_cost_usd
numeric ram_cost_usd
numeric pv_cost_usd
numeric network_cost_usd
boolean reconciled
timestamp window_start
timestamp window_end
timestamp recorded_at
}
HOSTING_CREDIT_LEDGER {
bigint id
bigint tenant_id
bigint environment_id
string type
string source
numeric raw_amount_usd
numeric charged_amount_usd
numeric markup_percent_snapshot
bigint hosting_cost_event_id
bigint billing_period_id
string ref_id
timestamp created_at
}
HOSTING_BILLING_ACCOUNT {
bigint id
bigint tenant_id
int billing_anchor_day
timestamp current_period_start
timestamp current_period_end
numeric accrued_charged_usd
string status
timestamp trial_ends_at
timestamp past_due_since
timestamp created_at
timestamp updated_at
}
HOSTING_INVOICE {
bigint id
bigint tenant_id
timestamp period_start
timestamp period_end
numeric total_charged_usd
string stripe_payment_intent_id
string status
int attempt_count
timestamp next_retry_at
timestamp created_at
timestamp settled_at
}
HOSTING_INVOICE_LINE {
bigint id
bigint invoice_id
bigint environment_id
numeric charged_amount_usd
string environment_name_snapshot
}
USER_BUDGET {
bigint id
bigint user_id
numeric ai_credit_usd
numeric spending_limit_usd
numeric hosting_spending_limit_usd
}
ENVIRONMENT ||--o{ HOSTING_COST_EVENTS : attributed_to
ENVIRONMENT ||--o{ HOSTING_CREDIT_LEDGER : attributed_to
ENVIRONMENT ||--o{ HOSTING_INVOICE_LINE : has
HOSTING_INVOICE ||--o{ HOSTING_INVOICE_LINE : contains
HOSTING_COST_EVENTS ||--o| HOSTING_CREDIT_LEDGER : reconciled_as
HOSTING_INVOICE ||--o{ HOSTING_CREDIT_LEDGER : settled_by
Nové / rozšířené tabulky (popis sloupců)
hosting_cost_events (rozšíření — přidává se environment_id)
Existující sloupce (z changesetu 0030): id, user_id, tenant_id, namespace, cost_usd, cpu_cost_usd, ram_cost_usd, pv_cost_usd, network_cost_usd, window_start, window_end, recorded_at.
| Sloupec | Typ | Constraints | Popis |
|---|---|---|---|
environment_id | BIGINT | NULL, FK → environment(id) | Mapování namespace → environment; NULL dokud backfill neproběhne; orphan namespace = NULL (NEfakturovat) |
reconciled | BOOLEAN | NOT NULL, default false | Přidáno v F2; false = čeká na akruál reconciler |
hosting_credit_ledger (nová tabulka)
| Sloupec | Typ | Constraints | Popis |
|---|---|---|---|
id | BIGINT | PK auto-increment | |
tenant_id | BIGINT | NOT NULL, FK → tenants(id) | |
environment_id | BIGINT | NULL, FK → environment(id) | Prostředí, ke kterému náklad patří |
type | VARCHAR(16) | NOT NULL | CREDIT nebo DEBIT |
source | VARCHAR(32) | NOT NULL | HOSTING_USAGE, INVOICE_SETTLEMENT, ADJUSTMENT, TRIAL_INCLUDED |
settlement_mode | VARCHAR(16) | DROPPED (be#142) — prepaid back-door zrušen 2026-05-23; sloupec neexistuje | |
raw_amount_usd | NUMERIC(20,6) | NOT NULL | Nezměněná raw částka |
charged_amount_usd | NUMERIC(20,6) | NOT NULL | raw * (1 + markup/100) |
markup_percent_snapshot | NUMERIC(6,3) | NOT NULL | Snapshot marže v okamžiku debitu |
hosting_cost_event_id | BIGINT | NULL, FK → hosting_cost_events(id) | Propojení na zdrojový event; UNIQUE WHERE source='HOSTING_USAGE' |
billing_period_id | BIGINT | NULL, FK → hosting_invoice(id) | Nastaveno při uzávěrce |
ref_id | VARCHAR(128) | NULL | Rezervováno (Stripe ID apod.) |
created_at | TIMESTAMPTZ | NOT NULL, default NOW() |
hosting_billing_account (nová tabulka)
| Sloupec | Typ | Constraints | Popis |
|---|---|---|---|
id | BIGINT | PK auto-increment | |
tenant_id | BIGINT | NOT NULL UNIQUE, FK → tenants(id) | 1:1 s tenantem |
billing_anchor_day | INT | NOT NULL, default 1 | Den v měsíci pro uzávěrku (seam DP-5) |
current_period_start | TIMESTAMPTZ | NOT NULL | Začátek aktuální billing periody |
current_period_end | TIMESTAMPTZ | NOT NULL | Konec aktuální billing periody (příští 1. v měsíci) |
accrued_charged_usd | NUMERIC(20,6) | NOT NULL, default 0 | Běžící součet charged za aktuální periodu |
status | VARCHAR(32) | NOT NULL, default ‘ACTIVE’ | ACTIVE, CAP_REACHED, PAST_DUE, SUSPENDED |
trial_ends_at | TIMESTAMPTZ | NULL | NULL = bez trialu nebo trial vypnutý (DP-1: trial_days=0) |
past_due_since | TIMESTAMPTZ | NULL | Nastaveno při přechodu do PAST_DUE |
created_at | TIMESTAMPTZ | NOT NULL, default NOW() | |
updated_at | TIMESTAMPTZ | NOT NULL, default NOW() |
hosting_invoice (nová tabulka)
| Sloupec | Typ | Constraints | Popis |
|---|---|---|---|
id | BIGINT | PK auto-increment | |
tenant_id | BIGINT | NOT NULL, FK → tenants(id) | |
period_start | TIMESTAMPTZ | NOT NULL | |
period_end | TIMESTAMPTZ | NOT NULL | |
total_charged_usd | NUMERIC(20,6) | NOT NULL | Σ per-env line items |
stripe_payment_intent_id | VARCHAR(255) | NULL | Nastaveno po volání Stripe |
status | VARCHAR(16) | NOT NULL | DRAFT, OPEN, PAID, FAILED, VOID |
attempt_count | INT | NOT NULL, default 0 | Počet Stripe pokusů |
next_retry_at | TIMESTAMPTZ | NULL | Plánovaný čas dalšího retry (dunning) |
created_at | TIMESTAMPTZ | NOT NULL, default NOW() | |
settled_at | TIMESTAMPTZ | NULL | Čas úspěšné platby (webhook) |
Unikátní constraint: (tenant_id, period_start, period_end) — jedna faktura per tenant per periodu.
hosting_invoice_line (nová tabulka — per-env řádky dle OD-6)
| Sloupec | Typ | Constraints | Popis |
|---|---|---|---|
id | BIGINT | PK auto-increment | |
invoice_id | BIGINT | NOT NULL, FK → hosting_invoice(id) | |
environment_id | BIGINT | NULL, FK → environment(id) | NULL pro orphan legacy řádky |
charged_amount_usd | NUMERIC(20,6) | NOT NULL | Charged za toto prostředí v dané periodě |
environment_name_snapshot | VARCHAR(100) | NOT NULL | Snapshot jména env v okamžiku fakturace |
user_budget — nový sloupec (rozšíření)
| Sloupec | Typ | Constraints | Popis |
|---|---|---|---|
hosting_spending_limit_usd | NUMERIC(10,2) | NULL | NULL = bez limitu. Nezávislé na spending_limit_usd (DP-2 LOCKED — NEreuse UC-10005) |
Liquibase changesety (F2)
PRODUCTION fáze: immutable pravidla. Každý soubor je NOVÝ, žádný existující se NEEDITUJE. F1 použilo
0033. F2 pokračuje od0034.
| # | Soubor | Obsah |
|---|---|---|
| 1 | 0034-add-environment-id-to-hosting-cost-events.xml | ALTER TABLE hosting_cost_events ADD COLUMN environment_id BIGINT NULL REFERENCES environment(id) + ADD COLUMN reconciled BOOLEAN NOT NULL DEFAULT false + backfill environment_id dle namespace → 'talkide' slug (UPDATE, single SQL, bezpečné) |
| 2 | 0035-create-hosting-credit-ledger.xml | Nová tabulka hosting_credit_ledger + UNIQUE index uq_ledger_cost_event_id WHERE source='HOSTING_USAGE' |
| 3 | 0036-create-hosting-billing-account.xml | Nová tabulka hosting_billing_account + UNIQUE constraint uq_billing_account_tenant na tenant_id |
| 4 | 0037-create-hosting-invoice.xml | Nová tabulka hosting_invoice + UNIQUE constraint uq_invoice_tenant_period na (tenant_id, period_start, period_end) |
| 5 | 0038-create-hosting-invoice-line.xml | Nová tabulka hosting_invoice_line |
| 6 | 0039-add-hosting-spending-limit-to-user-budget.xml | ALTER TABLE user_budget ADD COLUMN hosting_spending_limit_usd NUMERIC(10,2) NULL |
Backfill v changesetu 0034
-- Backfill: hosting_cost_events.environment_id pro stávající záznamy
-- Mapování namespace "tenant-{slug}" → environment kde slug = right-trim "tenant-" prefix
UPDATE hosting_cost_events hce
SET environment_id = e.id
FROM environment e
JOIN tenants t ON t.id = e.tenant_id
WHERE e.kind = 'DEFAULT'
AND hce.namespace = 'tenant-' || t.slug
AND hce.environment_id IS NULL;
Tento SQL je idempotentní (WHERE environment_id IS NULL) a neovlivňuje immutabilitu
existujících cost dat — jen doplní FK odkaz.
Backend implementace — klíčové komponenty
Nové Kotlin třídy (package struktura)
billing/hosting/
domain/
HostingBillingAccount.kt (JPA entity)
HostingCreditLedger.kt (JPA entity)
HostingInvoice.kt (JPA entity)
HostingInvoiceLine.kt (JPA entity)
repository/
HostingBillingAccountRepository.kt
HostingCreditLedgerRepository.kt
HostingInvoiceRepository.kt
service/
HostingAccrualService.kt (akruál logika, @Transactional)
HostingInvoiceService.kt (faktura + Stripe settle)
HostingDunningService.kt (dunning state machine)
HostingEstimateService.kt (FE breakdown výpočet)
batch/
HostingCostPollerBatch.kt (@Component, @SchedulerLock)
HostingAccrualReconcilerBatch.kt (@Component, @SchedulerLock)
HostingInvoiceBatch.kt (@Component, @SchedulerLock)
HostingDunningBatch.kt (@Component, @SchedulerLock)
controller/
HostingEstimateController.kt (GET /api/v1/billing/hosting/estimate)
AdminHostingReportController.kt (GET /api/v1/admin/billing/hosting/report)
Spring AOP self-invocation — batch procesory
// SPRÁVNĚ: samostatný @Component pro každý batch (obejde Spring AOP self-invocation)
@Component
class HostingAccrualReconcilerBatch(
private val accrualService: HostingAccrualService,
private val pricingService: PricingService
) {
@Scheduled(fixedDelay = 900_000) // 15 min
@SchedulerLock(name = "hostingAccrualReconciler", lockAtMostFor = "PT25M")
fun run() {
// volá accrualService.processUnreconciled() — @Transactional v service
}
}
// ŠPATNĚ (ZAKÁZÁNO): volat @Scheduled metodu z téže třídy — Spring AOP ji neodchytí
HostingAccrualService — jádro akruálu
@Service
@Transactional
class HostingAccrualService(
private val hostingCostEventRepository: HostingCostEventRepository,
private val creditLedgerRepository: HostingCreditLedgerRepository,
private val billingAccountRepository: HostingBillingAccountRepository,
private val pricingService: PricingService,
private val userBudgetRepository: UserBudgetRepository,
private val tenantRepository: TenantRepository
) {
/**
* Zpracuje dávku hosting_cost_events záznamů → DEBIT do ledgeru + inkrementace accrued.
* Idempotentní: ON CONFLICT DO NOTHING + DataIntegrityViolationException backstop.
* Markup se načte jednou před smyčkou (1 DB SELECT), pak se aplikuje per-event (čistá matematika).
*/
fun processUnreconciled() {
val markup = pricingService.getEffectiveMarkup() // 1× DB SELECT, mimo smyčku
val events = hostingCostEventRepository.findUnreconciled(limit = 500)
for (event in events) {
processOneEvent(event, markup.hostingMarkupPercent)
}
}
private fun processOneEvent(event: HostingCostEventEntity, markupPercent: BigDecimal) {
val chargedAmountUsd = pricingService.applyMarkup(event.costUsd, markupPercent)
// INSERT ... ON CONFLICT (hosting_cost_event_id) WHERE source='HOSTING_USAGE' DO NOTHING
try {
creditLedgerRepository.insertDebit(
tenantId = event.tenantId,
environmentId = event.environmentId,
rawAmountUsd = event.costUsd,
chargedAmountUsd = chargedAmountUsd,
markupPercentSnapshot = markupPercent,
hostingCostEventId = event.id
)
} catch (e: DataIntegrityViolationException) {
// backstop: event byl zpracován souběžně (race condition za ShedLockem)
return
}
// Zvyšujeme accrued_charged_usd
billingAccountRepository.incrementAccrued(event.tenantId, chargedAmountUsd)
// Cap check (DP-2)
checkAndEnforceCap(event.tenantId)
// Mark reconciled
hostingCostEventRepository.markReconciled(event.id)
}
private fun checkAndEnforceCap(tenantId: Long) {
// user_budget.user_id = FK na users.id (NE tenant_id).
// V alfě 1 tenant ≈ 1 user; userId se odvozuje z tenant-owner vztahu
// (tenant.owner_id → userId). UserBudgetRepository.findById(userId) vrátí Optional.
val account = billingAccountRepository.findByTenantId(tenantId) ?: return
val userId = tenantRepository.findById(tenantId).orElse(null)?.owner?.id ?: return
val budget = userBudgetRepository.findById(userId).orElse(null)
val limit = budget?.hostingSpendingLimitUsd ?: return // null = bez limitu
if (account.accruedChargedUsd >= limit) {
billingAccountRepository.updateStatus(tenantId, BillingStatus.CAP_REACHED)
// triggerCapAlertEmail — asynchronní, mimo @Transactional scope
}
}
}
HostingBillingAccountService — lazy get-or-create
@Service
@Transactional
class HostingBillingAccountService(
private val repository: HostingBillingAccountRepository
) {
/**
* Lazy get-or-create. Voláno při 1. Publish (stejný vzor jako F1 EnvironmentService).
* trial_ends_at = NOW() + trial_days pokud trial_days > 0, jinak null.
*/
fun getOrCreate(tenantId: Long): HostingBillingAccountEntity {
return repository.findByTenantId(tenantId) ?: run {
val trialDays = trialDaysConfig // @Value("${billing.hosting.trial-days:14}")
val trialEndsAt = if (trialDays > 0) Instant.now().plus(trialDays.toLong(), ChronoUnit.DAYS) else null
val now = Instant.now()
val periodEnd = nextFirstOfMonth(now)
repository.save(HostingBillingAccountEntity(
tenantId = tenantId,
billingAnchorDay = 1,
currentPeriodStart = now,
currentPeriodEnd = periodEnd,
accruedChargedUsd = BigDecimal.ZERO,
status = BillingStatus.ACTIVE,
trialEndsAt = trialEndsAt
))
}
}
}
HostingEstimateService — FE breakdown
@Service
@Transactional(readOnly = true)
class HostingEstimateService(
private val billingAccountRepository: HostingBillingAccountRepository,
private val creditLedgerRepository: HostingCreditLedgerRepository,
private val environmentRepository: EnvironmentRepository,
private val userBudgetRepository: UserBudgetRepository,
private val tenantRepository: TenantRepository
) {
fun getEstimate(tenantId: Long): HostingEstimateDto {
val account = billingAccountRepository.findByTenantId(tenantId)
?: return HostingEstimateDto.empty(tenantId)
val envAccruals = creditLedgerRepository.sumChargedByEnvironment(tenantId, account.currentPeriodStart)
val projected = calculateProjected(account.accruedChargedUsd, account.currentPeriodStart)
// user_budget.user_id = FK na users.id (NE tenant_id).
// userId se odvozuje z tenant.owner_id; v alfě 1 tenant ≈ 1 user, ale jsou to jiná ID.
val userId = tenantRepository.findById(tenantId).orElse(null)?.owner?.id
val budget = userId?.let { userBudgetRepository.findById(it).orElse(null) }
return HostingEstimateDto(
currentPeriodStart = account.currentPeriodStart,
currentPeriodEnd = account.currentPeriodEnd,
nextInvoiceDate = account.currentPeriodEnd.atZone(ZoneOffset.UTC).toLocalDate(),
status = account.status,
trialEndsAt = account.trialEndsAt,
accruedChargedUsd = account.accruedChargedUsd,
projectedMonthlyUsd = projected,
hostingSpendingLimitUsd = budget?.hostingSpendingLimitUsd,
disclaimer = "Projected amount is an estimate, not a binding invoice.",
environments = envAccruals.map { (envId, accrued) ->
val env = environmentRepository.findById(envId).orElse(null)
HostingEnvEstimateDto(
environmentId = envId,
environmentName = env?.name ?: "Unknown",
environmentSlug = env?.slug ?: "",
accruedChargedUsd = accrued,
projectedChargedUsd = calculateProjected(accrued, account.currentPeriodStart)
)
}
)
}
private fun calculateProjected(accrued: BigDecimal, periodStart: Instant): BigDecimal {
val now = Instant.now()
val daysElapsed = ChronoUnit.DAYS.between(periodStart, now).coerceAtLeast(1)
val daysInMonth = periodStart.atZone(ZoneOffset.UTC).toLocalDate().lengthOfMonth().toLong()
return accrued
.multiply(BigDecimal.valueOf(daysInMonth))
.divide(BigDecimal.valueOf(daysElapsed), 6, RoundingMode.HALF_UP)
}
}
Test profile H2 — pozor na JSONB a reserved words
V test profilu (application-test.yaml) H2:
HostingBillingAccountEntityaHostingCreditLedgerEntitynepoužívajícolumnDefinition = "JSONB"(H2 nezná JSONB — pokudconfigbude nullable String bezcolumnDefinition).- Aliasy v JPQL/native queries: nepoužívat H2 reserved words (
VALUE,TYPE,DATE,TIME,PERIOD). Bezpečné:env_id,env_name,line_amount,source_type(viz be#124 day-alias precedens). @DisabledInAotModena testovacích třídách s@MockBean+@SpringBootTest.
Frontend
Stránka — Billing / Hosting
F2 přidává novou sekci „Hosting” do billing stránky (/billing nebo /settings/billing).
Komponenty:
HostingEstimateWidget— zobrazíaccruedChargedUsd,projectedMonthlyUsd,nextInvoiceDateHostingEnvironmentTable— per-env breakdown (tabulka senvironmentName,accruedChargedUsd,projectedChargedUsd)BillingStatusBanner— pokudstatus ∈ {PAST_DUE, SUSPENDED, CAP_REACHED}, zobrazí barevný banner s textem a výzvou k akci- Disclaimer —
"Projected amount is an estimate, not a binding invoice."
Validations
Žádný user-facing formulář pro F2 (FE jen čte data). Hosting spend cap se nastavuje přes
existující billing settings (budoucí endpoint PUT /api/v1/billing/hosting/spending-limit
— mimo scope F2; F2 jen čte limit z user_budget.hosting_spending_limit_usd).
Backend
Validations — GET /api/v1/billing/hosting/estimate
| Field | Constraints | Note |
|---|---|---|
| JWT token | not_null, valid, not_expired | 401 AUTHENTICATION_FAILED |
| tenant kontext | not_null | odvozeno z tokenu; chybějící → 401 |
Validations — GET /api/v1/admin/billing/hosting/report
| Field | Constraints | Note |
|---|---|---|
| JWT token | not_null, valid, not_expired, ROLE_ADMIN | 401 AUTHENTICATION_FAILED (silent-probe — ne-admin dostane stejné 401 jako neautentizovaný) |
Invarianty
| Invariant | Enforcement |
|---|---|
Markup aplikace: charged = raw * (1 + percent/100), BigDecimal.valueOf, setScale(6, HALF_UP) | PricingService.applyMarkup (UC-10008 kontrakt, beze změny) |
Markup=0 ⇒ charged == raw (žádná regrese) | Existující test z UC-10008 + nové TC v sekci níže |
NUMERIC(20,6) pro všechna USD pole | DB schema + Kotlin BigDecimal |
hosting_cost_events immutable | Nikdy UPDATE cost_usd ani breakdown sloupce; jen reconciled=true |
hosting_credit_ledger append-only | Žádný DELETE; žádný UPDATE mimo billing_period_id při uzávěrce |
| Idempotence akruálu | UNIQUE index na (hosting_cost_event_id) WHERE source='HOSTING_USAGE' + DataIntegrityViolationException catch |
| Multi-pod safety | ShedLock @SchedulerLock pro každou batch úlohu |
| Spring profil batch beanů | @Profile("production") (NIKDY @Profile("prod")) |
| SOFT enforcement only v F2 | status=SUSPENDED = pouze DB záznam; žádný K8s volání |
todo-list.talkide.app nedotčen | Guaranteed by design — žádná infra akce v F2 |
Test Cases
| ID | GIVEN | WHEN | THEN |
|---|---|---|---|
| TC-1 | Tenant „popelkam” s 1 DEFAULT prostředím „TalkIDE”, hosting_cost_events mají 1 řádek namespace=tenant-popelkam, cost_usd=10.00, reconciled=false, hosting_markup_percent=20 | HostingAccrualReconcilerBatch.run() zavolán | Vznikne hosting_credit_ledger DEBIT řádek: raw_amount_usd=10.000000, charged_amount_usd=12.000000, markup_percent_snapshot=20.000, source=HOSTING_USAGE; hosting_billing_account.accrued_charged_usd=12.000000; hosting_cost_events.reconciled=true |
| TC-2 | Markup hosting_markup_percent=0, cost event cost_usd=10.00 | HostingAccrualReconcilerBatch.run() zavolán | charged_amount_usd=10.000000 — bit-identické s raw (žádná regrese při markup=0) |
| TC-3 | Reconciler volán 2× na stejný hosting_cost_event_id (simulace retry) | HostingAccrualReconcilerBatch.run() (2. běh) | Druhý INSERT do hosting_credit_ledger zachycen UNIQUE constraintem nebo DataIntegrityViolationException; accrued_charged_usd se nezdvojí; idempotence splněna |
| TC-4 | hosting_billing_account.trial_ends_at = future (tenant v trialu) | HostingInvoiceBatch.run() spuštěn 1. v měsíci | Žádná faktura pro tohoto tenanta nevznikne; tenant stále v trial periodě |
| TC-5 | trial_ends_at = null (trial_days=0 nebo trial vypršel), accrued_charged_usd=15.00, měsíc uzavřen | HostingInvoiceBatch.run() spuštěn 1. v měsíci | Vznikne hosting_invoice s total_charged_usd=15.000000, status=DRAFT; vznikne hosting_invoice_line s environment_name_snapshot=TalkIDE, charged_amount_usd=15.000000 |
| TC-6 | accrued_charged_usd=0.00 za celou periodu (žádné cost events) | HostingInvoiceBatch.run() | Žádná faktura nevznikne (nulová částka = no-op) |
| TC-7 | Stripe PaymentIntent vrátí succeeded synchronně | HostingInvoiceBatch.run() | hosting_invoice.status=OPEN, stripe_payment_intent_id nastaven; webhook payment_intent.succeeded → status=PAID, accrued_charged_usd=0, period se posune |
| TC-8 | Stripe PaymentIntent vrátí chybu (declined) | HostingInvoiceBatch.run() | hosting_invoice.status=FAILED, attempt_count=1, next_retry_at=NOW()+2d; hosting_billing_account.status=PAST_DUE |
| TC-9 | Faktura status=FAILED, attempt_count=1, next_retry_at <= NOW() | HostingDunningBatch.run() | Stripe retry; pokud stále failed → attempt_count=2, next_retry_at=NOW()+5d; dunning email odeslán |
| TC-10 | Faktura status=FAILED, attempt_count=2, next_retry_at <= NOW() (3. pokus) | HostingDunningBatch.run() | Po failed retry: attempt_count=3, status=FAILED (final), hosting_billing_account.status=PAST_DUE; past_due email odeslán |
| TC-11 | hosting_billing_account.status=PAST_DUE, past_due_since < NOW()-7d | HostingDunningBatch.run() | hosting_billing_account.status=SUSPENDED; suspended email odeslán; žádný K8s scale-to-zero, žádná infra akce |
| TC-12 | user_budget.hosting_spending_limit_usd=50.00, aktuální accrued_charged_usd=49.00, nový cost event charged=2.00 | Reconciler zpracovává event | accrued_charged_usd=51.00 ≥ limit → hosting_billing_account.status=CAP_REACHED; cap alert email odeslán; další reconcile pro tohoto tenanta přeskočeny dokud status = CAP_REACHED |
| TC-13 | user_budget.hosting_spending_limit_usd=null (bez limitu) | Reconciler zpracovává event | Cap check je no-op; akruál pokračuje bez omezení |
| TC-14 | Autentizovaný user v trial periodě | GET /api/v1/billing/hosting/estimate | 200 OK; trialEndsAt vyplněno; accruedChargedUsd=0; environments obsahuje „TalkIDE” řádku |
| TC-15 | Autentizovaný user po skončení trialu s 2 prostředími (TalkIDE + PROD) | GET /api/v1/billing/hosting/estimate | 200 OK; environments obsahuje 2 řádky; součet per-env accruedChargedUsd = accruedChargedUsd na root úrovni |
| TC-16 | Neautentizovaný user | GET /api/v1/billing/hosting/estimate | 401 AUTHENTICATION_FAILED |
| TC-17 | Neadmin user | GET /api/v1/admin/billing/hosting/report | 401 AUTHENTICATION_FAILED (silent-probe — stejné jako neautentizovaný) |
| TC-18 | Admin user | GET /api/v1/admin/billing/hosting/report | 200 OK; seznam tenantů s billing stavy |
| TC-19 | todo-list.talkide.app (popelkam) aktivní při nasazení F2 changesetů + spuštění backfill | Nasazení + backfill | todo-list.talkide.app vrací HTTP 200; hosting_cost_events mají environment_id doplněn backfillem; žádný pod nerestaroval |
| TC-20 | hosting_invoice_batch spuštěn 2× ve stejný den (simulace retry scheduler) | HostingInvoiceBatch.run() (2. spuštění) | UNIQUE constraint (tenant_id, period_start, period_end) na hosting_invoice zabrání duplicitní faktuře; ShedLock zabrání souběžnému spuštění |
| TC-21 | Namespace unknown-ns v OpenCost datech (žádné environment s tímto namespace_ref) | HostingCostPollerBatch.run() | Orphan namespace: žádný hosting_cost_events INSERT pro tento ns; WARN log + metrika orphan_namespace++; žádné fakturace |
Acceptance kritéria (pro backend developera)
- Markup invariant:
applyMarkup(raw, 0) = raw(bit-identické) — TC-2 zelený. Markup=0 → žádná regrese oproti chování před F2. - Idempotence akruálu: opakovaný reconcile téhož
hosting_cost_event_idneprodukuje duplicitníhosting_credit_ledgerřádek ani nezdvojíaccrued_charged_usd— TC-3 zelený. - Trial respektován: tenant v trialu nedostane fakturu — TC-4 zelený.
- Nulová faktura: perioda bez cost events → žádná
hosting_invoice— TC-6 zelený. - Per-environment řádky na faktuře:
hosting_invoice_lineobsahuje řádek pro každé prostředí tenanta s nenulovým akruálem (OD-6) — TC-5 zelený. - SOFT enforcement:
status=SUSPENDEDje výhradně DB záznam; žádný Kubernetes API volání se neuskuteční — TC-11 zelený (ověřit absencíKubernetesClientmock volání). - Cap enforcement: při překročení
hosting_spending_limit_usdse akruál zastaví a alert email odeslán — TC-12 zelený. - Orphan namespace: namespace bez mapování na
environment→ WARN log, nula fakturace — TC-21 zelený. todo-list.talkide.appnedotčen: po nasazení F2 a spuštění backfill jetodo-list.talkide.appstále dostupný — TC-19 zelený.- Multi-pod safety: každá batch úloha má
@SchedulerLock; UNIQUE DB constrainty jsou poslední obrana; žádná duplicitní faktura při souběžném spuštění — TC-20 zelený. - Spring profil: batch beany a plánované úlohy jsou
@Profile("production"); v testovacím profilu H2 nejsou aktivní (testy nevolají scheduler přímo). - H2 kompatibilita: testovací suite
./gradlew testzelená bez H2 chyb (JSONB → nullable String bezcolumnDefinition, bezpečné aliasy). @DisabledInAotMode: všechny testovací třídy s@MockBean+@SpringBootTestmají tuto anotaci (be#56 invariant).- Staré BE testy zelené:
./gradlew testbez regrese po přidání F2 kódu.
Odchylky implementace od UC diagramu
Odchylka A.1 — Known tenant namespace bez environment match (Subsystém A)
| Aspekt | UC diagram (sekce A) | Skutečná implementace |
|---|---|---|
| Chování | Žádný INSERT (zahazovat event) | INSERT do hosting_cost_events s environment_id=NULL + WARN log |
| Dopad na fakturaci | Nefakturováno | Fakturováno jako hosting_invoice_line s environment_name_snapshot='Unknown' |
Scénář: Namespace začíná prefixem tenant- (= billable tenant namespace), tenant v DB existuje, ale žádná EnvironmentEntity nemá namespace_ref odpovídající tomuto namespacu (typicky mezera po backfillu nebo race condition F1/F2 nasazení).
Důvod odchylky: Konzervativní zachování raw cost dat. Pokud by se event zahodil a provozovatel reálné K8s náklady měl, byl by při fakturaci nechtěně subvencován. S environment_id=NULL jsou náklady viditelné v hosting_cost_events, fakturují se jako „Unknown” env řádek a po doplnění backfillu lze zpětně ověřit správnost. Pro tenant je výsledek stejný (platí), ale data se neztratí a admin report je konzistentní.
Validace: RecordHostingCostBatchProcessorTestCases.kt → test GIVEN known tenant namespace with no environment match WHEN persistOne THEN cost event recorded with null environment_id. HostingAccrualTestCases.kt → test GIVEN cost event with null environment_id WHEN reconciler runs THEN DEBIT recorded with null env and contributes to accrued.
Závislosti a předpoklady
| Závislost | Stav |
|---|---|
F1 nasazena (tabulka environment, lazy default „TalkIDE”, backfill) | Musí být PŘED F2 |
EnvironmentRepository.findByNamespaceRef(ns: String) | Přidat do F2 (F1 neměl) |
| OpenCost nasazen v K8s clusteru (talkide-infra) | Nutné pro C.2 scrape |
| Mailgun (ADR-025) funkční | Nutné pro dunning e-maily |
PricingService.applyMarkup (UC-10008) | Existuje, beze změny |
Stripe uložená karta na hosting_billing_account.stripe_customer_id | Přes users.stripe_customer_id (existuje z UC-10001) |
application.yaml property billing.hosting.trial-days (default 14) | Nová config property; 0 = trial vypnutý (DP-1) |
Thanks for the feedback.