Internal Documentation internal
TalkIDE internal documentation

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 (environment tabulka, 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 → tenant na namespace → environment přidáním environment_id FK.
  • Postpaid akruální pipeline: raw hosting_cost_events → charged DEBIT řádek do hosting_credit_ledger → inkrementace hosting_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_at nastaven při 1. Publish na NOW() + trial_days; trial_days=0trial_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-10005 spending_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 → SUSPENDED na záznamu hosting_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 @SchedulerLock pro každou plánovanou úlohu (multi-pod idempotence).
  • Batch processing v samostatném @Component bean (obejití Spring AOP self-invocation).

Přehled subsystémů F2

F2 se skládá ze čtyř navzájem navazujících subsystémů:

#SubsystémTriggerKlíčové entity
AC.2 OpenCost scrape + environment attribution@Scheduled periodickyhosting_cost_events, environment
BPostpaid akruální reconciler@Scheduled periodickyhosting_credit_ledger, hosting_billing_account
CMěsíční faktura + Stripe settle@Scheduled 1. v měsícihosting_invoice, hosting_invoice_line
DDunning pipeline@Scheduled denně + hosting_invoice webhookhosting_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ěrkyAkce
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 pokusechStatus → 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.

SloupecTypConstraintsPopis
environment_idBIGINTNULL, FK → environment(id)Mapování namespace → environment; NULL dokud backfill neproběhne; orphan namespace = NULL (NEfakturovat)
reconciledBOOLEANNOT NULL, default falsePřidáno v F2; false = čeká na akruál reconciler

hosting_credit_ledger (nová tabulka)

SloupecTypConstraintsPopis
idBIGINTPK auto-increment
tenant_idBIGINTNOT NULL, FK → tenants(id)
environment_idBIGINTNULL, FK → environment(id)Prostředí, ke kterému náklad patří
typeVARCHAR(16)NOT NULLCREDIT nebo DEBIT
sourceVARCHAR(32)NOT NULLHOSTING_USAGE, INVOICE_SETTLEMENT, ADJUSTMENT, TRIAL_INCLUDED
settlement_modeVARCHAR(16)DROPPED (be#142) — prepaid back-door zrušen 2026-05-23; sloupec neexistuje
raw_amount_usdNUMERIC(20,6)NOT NULLNezměněná raw částka
charged_amount_usdNUMERIC(20,6)NOT NULLraw * (1 + markup/100)
markup_percent_snapshotNUMERIC(6,3)NOT NULLSnapshot marže v okamžiku debitu
hosting_cost_event_idBIGINTNULL, FK → hosting_cost_events(id)Propojení na zdrojový event; UNIQUE WHERE source='HOSTING_USAGE'
billing_period_idBIGINTNULL, FK → hosting_invoice(id)Nastaveno při uzávěrce
ref_idVARCHAR(128)NULLRezervováno (Stripe ID apod.)
created_atTIMESTAMPTZNOT NULL, default NOW()

hosting_billing_account (nová tabulka)

SloupecTypConstraintsPopis
idBIGINTPK auto-increment
tenant_idBIGINTNOT NULL UNIQUE, FK → tenants(id)1:1 s tenantem
billing_anchor_dayINTNOT NULL, default 1Den v měsíci pro uzávěrku (seam DP-5)
current_period_startTIMESTAMPTZNOT NULLZačátek aktuální billing periody
current_period_endTIMESTAMPTZNOT NULLKonec aktuální billing periody (příští 1. v měsíci)
accrued_charged_usdNUMERIC(20,6)NOT NULL, default 0Běžící součet charged za aktuální periodu
statusVARCHAR(32)NOT NULL, default ‘ACTIVE’ACTIVE, CAP_REACHED, PAST_DUE, SUSPENDED
trial_ends_atTIMESTAMPTZNULLNULL = bez trialu nebo trial vypnutý (DP-1: trial_days=0)
past_due_sinceTIMESTAMPTZNULLNastaveno při přechodu do PAST_DUE
created_atTIMESTAMPTZNOT NULL, default NOW()
updated_atTIMESTAMPTZNOT NULL, default NOW()

hosting_invoice (nová tabulka)

SloupecTypConstraintsPopis
idBIGINTPK auto-increment
tenant_idBIGINTNOT NULL, FK → tenants(id)
period_startTIMESTAMPTZNOT NULL
period_endTIMESTAMPTZNOT NULL
total_charged_usdNUMERIC(20,6)NOT NULLΣ per-env line items
stripe_payment_intent_idVARCHAR(255)NULLNastaveno po volání Stripe
statusVARCHAR(16)NOT NULLDRAFT, OPEN, PAID, FAILED, VOID
attempt_countINTNOT NULL, default 0Počet Stripe pokusů
next_retry_atTIMESTAMPTZNULLPlánovaný čas dalšího retry (dunning)
created_atTIMESTAMPTZNOT NULL, default NOW()
settled_atTIMESTAMPTZNULLČ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)

SloupecTypConstraintsPopis
idBIGINTPK auto-increment
invoice_idBIGINTNOT NULL, FK → hosting_invoice(id)
environment_idBIGINTNULL, FK → environment(id)NULL pro orphan legacy řádky
charged_amount_usdNUMERIC(20,6)NOT NULLCharged za toto prostředí v dané periodě
environment_name_snapshotVARCHAR(100)NOT NULLSnapshot jména env v okamžiku fakturace

user_budget — nový sloupec (rozšíření)

SloupecTypConstraintsPopis
hosting_spending_limit_usdNUMERIC(10,2)NULLNULL = 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 od 0034.

#SouborObsah
10034-add-environment-id-to-hosting-cost-events.xmlALTER 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é)
20035-create-hosting-credit-ledger.xmlNová tabulka hosting_credit_ledger + UNIQUE index uq_ledger_cost_event_id WHERE source='HOSTING_USAGE'
30036-create-hosting-billing-account.xmlNová tabulka hosting_billing_account + UNIQUE constraint uq_billing_account_tenant na tenant_id
40037-create-hosting-invoice.xmlNová tabulka hosting_invoice + UNIQUE constraint uq_invoice_tenant_period na (tenant_id, period_start, period_end)
50038-create-hosting-invoice-line.xmlNová tabulka hosting_invoice_line
60039-add-hosting-spending-limit-to-user-budget.xmlALTER 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:

  • HostingBillingAccountEntity a HostingCreditLedgerEntity nepoužívají columnDefinition = "JSONB" (H2 nezná JSONB — pokud config bude nullable String bez columnDefinition).
  • 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).
  • @DisabledInAotMode na 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, nextInvoiceDate
  • HostingEnvironmentTable — per-env breakdown (tabulka s environmentName, accruedChargedUsd, projectedChargedUsd)
  • BillingStatusBanner — pokud status ∈ {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

FieldConstraintsNote
JWT tokennot_null, valid, not_expired401 AUTHENTICATION_FAILED
tenant kontextnot_nullodvozeno z tokenu; chybějící → 401

Validations — GET /api/v1/admin/billing/hosting/report

FieldConstraintsNote
JWT tokennot_null, valid, not_expired, ROLE_ADMIN401 AUTHENTICATION_FAILED (silent-probe — ne-admin dostane stejné 401 jako neautentizovaný)

Invarianty

InvariantEnforcement
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 poleDB schema + Kotlin BigDecimal
hosting_cost_events immutableNikdy 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áluUNIQUE index na (hosting_cost_event_id) WHERE source='HOSTING_USAGE' + DataIntegrityViolationException catch
Multi-pod safetyShedLock @SchedulerLock pro každou batch úlohu
Spring profil batch beanů@Profile("production") (NIKDY @Profile("prod"))
SOFT enforcement only v F2status=SUSPENDED = pouze DB záznam; žádný K8s volání
todo-list.talkide.app nedotčenGuaranteed by design — žádná infra akce v F2

Test Cases

IDGIVENWHENTHEN
TC-1Tenant „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=20HostingAccrualReconcilerBatch.run() zavolánVznikne 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-2Markup hosting_markup_percent=0, cost event cost_usd=10.00HostingAccrualReconcilerBatch.run() zavoláncharged_amount_usd=10.000000 — bit-identické s raw (žádná regrese při markup=0)
TC-3Reconciler 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-4hosting_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-5trial_ends_at = null (trial_days=0 nebo trial vypršel), accrued_charged_usd=15.00, měsíc uzavřenHostingInvoiceBatch.run() spuštěn 1. v měsíciVznikne 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-6accrued_charged_usd=0.00 za celou periodu (žádné cost events)HostingInvoiceBatch.run()Žádná faktura nevznikne (nulová částka = no-op)
TC-7Stripe PaymentIntent vrátí succeeded synchronněHostingInvoiceBatch.run()hosting_invoice.status=OPEN, stripe_payment_intent_id nastaven; webhook payment_intent.succeededstatus=PAID, accrued_charged_usd=0, period se posune
TC-8Stripe 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-9Faktura 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-10Faktura 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-11hosting_billing_account.status=PAST_DUE, past_due_since < NOW()-7dHostingDunningBatch.run()hosting_billing_account.status=SUSPENDED; suspended email odeslán; žádný K8s scale-to-zero, žádná infra akce
TC-12user_budget.hosting_spending_limit_usd=50.00, aktuální accrued_charged_usd=49.00, nový cost event charged=2.00Reconciler zpracovává eventaccrued_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-13user_budget.hosting_spending_limit_usd=null (bez limitu)Reconciler zpracovává eventCap check je no-op; akruál pokračuje bez omezení
TC-14Autentizovaný user v trial perioděGET /api/v1/billing/hosting/estimate200 OK; trialEndsAt vyplněno; accruedChargedUsd=0; environments obsahuje „TalkIDE” řádku
TC-15Autentizovaný user po skončení trialu s 2 prostředími (TalkIDE + PROD)GET /api/v1/billing/hosting/estimate200 OK; environments obsahuje 2 řádky; součet per-env accruedChargedUsd = accruedChargedUsd na root úrovni
TC-16Neautentizovaný userGET /api/v1/billing/hosting/estimate401 AUTHENTICATION_FAILED
TC-17Neadmin userGET /api/v1/admin/billing/hosting/report401 AUTHENTICATION_FAILED (silent-probe — stejné jako neautentizovaný)
TC-18Admin userGET /api/v1/admin/billing/hosting/report200 OK; seznam tenantů s billing stavy
TC-19todo-list.talkide.app (popelkam) aktivní při nasazení F2 changesetů + spuštění backfillNasazení + backfilltodo-list.talkide.app vrací HTTP 200; hosting_cost_events mají environment_id doplněn backfillem; žádný pod nerestaroval
TC-20hosting_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-21Namespace 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)

  1. Markup invariant: applyMarkup(raw, 0) = raw (bit-identické) — TC-2 zelený. Markup=0 → žádná regrese oproti chování před F2.
  2. Idempotence akruálu: opakovaný reconcile téhož hosting_cost_event_id neprodukuje duplicitní hosting_credit_ledger řádek ani nezdvojí accrued_charged_usd — TC-3 zelený.
  3. Trial respektován: tenant v trialu nedostane fakturu — TC-4 zelený.
  4. Nulová faktura: perioda bez cost events → žádná hosting_invoice — TC-6 zelený.
  5. Per-environment řádky na faktuře: hosting_invoice_line obsahuje řádek pro každé prostředí tenanta s nenulovým akruálem (OD-6) — TC-5 zelený.
  6. SOFT enforcement: status=SUSPENDED je výhradně DB záznam; žádný Kubernetes API volání se neuskuteční — TC-11 zelený (ověřit absencí KubernetesClient mock volání).
  7. Cap enforcement: při překročení hosting_spending_limit_usd se akruál zastaví a alert email odeslán — TC-12 zelený.
  8. Orphan namespace: namespace bez mapování na environment → WARN log, nula fakturace — TC-21 zelený.
  9. todo-list.talkide.app nedotčen: po nasazení F2 a spuštění backfill je todo-list.talkide.app stále dostupný — TC-19 zelený.
  10. 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ý.
  11. Spring profil: batch beany a plánované úlohy jsou @Profile("production"); v testovacím profilu H2 nejsou aktivní (testy nevolají scheduler přímo).
  12. H2 kompatibilita: testovací suite ./gradlew test zelená bez H2 chyb (JSONB → nullable String bez columnDefinition, bezpečné aliasy).
  13. @DisabledInAotMode: všechny testovací třídy s @MockBean + @SpringBootTest mají tuto anotaci (be#56 invariant).
  14. Staré BE testy zelené: ./gradlew test bez 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)

AspektUC 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 fakturaciNefakturovánoFakturová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ávislostStav
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_idPř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)

Was this page helpful?

Thanks for the feedback.