# billing — AGENTS.md

> TiDB Cloud billing / invoicing / payments / promotion / subscription service.
>
> This file documents the `billing` repo in isolation. Do not
> reference files outside this repo (for example a parent
> workspace `AGENTS.md`); the repo should be readable and
> self-contained on its own.

## Overview

- **Responsibility**: Modular DDD-style billing engine providing customer billing, AWS Marketplace / Aliyun Marketplace integration, Stripe credit cards, Promo Code/Credit, invoicing, reconciliation, Spend Limit, Cost Quota, Risk Control, renewal, closure, Referral. Exposes three external API surfaces: MSP, Partner, UNC.
- **Tech stack**: Go 1.25.4 · `gin` + `go-redis` + `gorm` + `gormigrate` + `stripe/stripe-go/v76` + `aws-sdk-go` (S3 bill detail) + Aliyun Marketplace integration + `shopspring/decimal` for money + Prometheus + `zsais/go-gin-prometheus` + `viper` + `pingcap/log` + `zap` + `sentry-go` + `grpc-gateway/v2` + `tidbcloud/tiinf/sdk/auditlog` + `tieventbus` + `form3tech-oss/jwt-go` + form3 SCM.
- **Shared dependencies**: [`pkgv2`](../pkgv2/AGENTS.md), [`tiinf`](../tiinf/AGENTS.md).
- **Local main SHA**: `271f3498`.

## Directory layout

```
billing/
├── cmd/
│   ├── server/main.go               # HTTP main service
│   ├── worker/main.go               # Background reconciliation worker
│   ├── stress/main.go               # Stress harness
│   └── promotion_worker/main.go     # Promotion worker
├── api/                             # 60+ router files
│   ├── router.go                    # Main router
│   ├── router_openapi.go            # OpenAPI subset
│   ├── router_msp_openapi.go        # MSP open API
│   ├── router_partners_openapi.go   # Partner API
│   ├── router_partners_privateapi.go# Partner private API
│   ├── router_inner_api.go          # Internal API
│   └── ... (60+)
├── handler/                         # Business-handler layer
│   ├── auditloghandler/ awshandler/ cachehandler/ centralhandler/
│   ├── cloudmarketplacehandler/ cloudproviderhandler/ eventbushandler/
│   ├── gcphandler/ infrahandler/ mailhandler/ metricshandler/
│   ├── novuhandler/ paymenthandler/ ratehandler/ salesforcehandler/
│   ├── sqsmeasurehandler/ tidbmgmtsvchandler/ workerhandler/
├── model/                           # Domain models
│   ├── aliyunsubscriptionmngt/ awssubscriptionmngt/ billing/ billingconfig/
│   ├── control/ costexplorer/ creditriskcontrol/ managementcommodity/
│   ├── marketing/ migrate/ partner/ payment/ unc/
├── worker/                          # 21 worker goroutines
│   ├── ar_table/ billing_config/ bills/ binded_package/
│   ├── check_billing_accuracy/ cost_quota/ credits/ discount/
│   ├── invoice/ marketplace_payment/ measure/
│   ├── no_traffic_dedicated_cluster_checker/ plan/ recharge_credit/
│   ├── reconciliation/ refund/ s3_bills_detail/ spend_limit/
│   ├── support_plan/ tenant_close/
├── promotion/                       # Marketing: referral / new_user / community reward
├── forms/                           # HTTP form / request / response structs
├── middleware/                      # CORS / header / openapi / rbac / user / apikey
├── db/{entity,test,interface,doc}.go  # GORM + auto-generated entity
├── config/                          # viper config + revive lint
├── utils/                           # 30+ utility sub-packages
├── billing_errors/                  # Error code definitions
├── constant/ migrate/ idl/idl/{core,dataflow}/
├── build/Dockerfile                 # Multi-stage build
└── tools/                           # goqueryset legacy ORM tools
```

## Build / test / deploy

| Task | Command |
|------|---------|
| Build | `make build` (compiles in docker) |
| Test | `make test` (spins up a MySQL 8.0 container and runs gotestsum) |
| Generate Swagger | `make swagger` (emits 4 openapi bundles: `docs/openapi`, `docs/msp_openapi`, `docs/partners_openapi`, `docs/partners_private_api`) |
| Docker | `docker build -f build/Dockerfile` |

## Entry points

| Service | Entry |
|---------|-------|
| HTTP main service | `cmd/server/main.go` |
| Background reconciliation worker | `cmd/worker/main.go` |
| Stress harness | `cmd/stress/main.go` |
| Promotion worker | `cmd/promotion_worker/main.go` |

## Key modules / business domains

- **Billing engine**: `measure` (metering) / `costexplorer` (cost query) / `invoice` (invoicing) / `usage_explorer` / `plan`
- **Payments**: Stripe credit card, AWS Marketplace, Aliyun Subscription, PayPal/Paymentech, UNC (enterprise contract), reconciliation
- **Promotion**: credits / discount / referral / community / new-user reward
- **Risk control**: cost_quota / credit_card_verify / risk_level / tenant_close
- **Resource package**: binded_package / spend_limit / recharge_plan
- **Cloud pricing impls**: AWS / GCP / Azure
- **External systems**: Novu notifications, Salesforce CRM, Jira, JSM

## Billing config types and partner-managed support fees

All partner relationships in this codebase are **1:N** — one partner tenant
has many customer tenants. What differs across the three partner modes is
**where the support plan is bound** and **how the partner's books are
partitioned** (`db/entity/entity.go`):

| Type | Where is support bound? | Partner BC structure | Support fee push |
|------|-------------------------|----------------------|------------------|
| `MSPCustomer` / `MSP` | **Partner** | 1 partner BC | n/a — partner binds and computes from its own `user_unit_costs` (see `MspCustomerSyncSupportPlan`). `SendReseller*` is never called. |
| `MarketplaceResellerManagedCustomer` / `MarketplaceReseller` (CPPO) | **Customer** | **N partner BCs** (one per customer) | `SendReseller*` pushes per-customer fee to the customer's dedicated partner BC |
| `ResaleCustomer` / `ResalePartner` | **Customer** | **1 partner BC** (shared by all customers) | `SendReseller*` pushes per-customer fee to the shared partner BC, attributed via `MarketplaceResellerManagedCustomerIDLabel` |
| `DirectCustomer` | Customer (self) | n/a | No partner push |

The "1:1 vs 1:N" framing is a common source of confusion: the partner-tenant
relationship is always 1:N. What is 1:1 for CPPO is the **billing-config
pair** (each customer has exactly one dedicated partner BC). Per the
source-vs-target split below, the **source of the support-fee base is always
the customer's own usage**, never the partner's aggregate — for CPPO this is
indistinguishable from the partner's per-customer BC, and for Resale it is
the only correct semantic (the partner's books aggregate all customers).

### Support fee flow (source/target split, CPPO and Resale only)

When a support plan is bound to a CPPO or Resale customer, the support plan
worker (`workers/support_plan_worker.go`) runs on the **customer's tenant**
and pushes support fees to the partner:

1. `SendNormalMetering` → writes daily support fee to the customer's own
   billing config (source == target == customer).
2. `SendResellerNormalMeteringIfNeed` → reads the customer's own usage base
   via `getCustomerUsageBase` (source = customer), writes the fee to the
   partner's billing config via `SecondaryMeasure` (target = partner).
3. `SendCommitmentMetering` → writes monthly commitment gap to customer.
4. `SendResellerCommitmentMeteringIfNeed` → reads the customer's own
   support-fee spend via `getCustomerSupportFeeSpend` (source = customer),
   writes the gap to partner (target = partner).

For CPPO, source (customer BC) and target (customer's dedicated partner BC)
hold the same aggregate for fee purposes — the per-customer partner BC
mirrors the customer. For Resale, source and target differ: the partner BC
aggregates all N customers, but the fee base is read from each customer's
own BC and attributed to that customer via the
`MarketplaceResellerManagedCustomerIDLabel`. Computing from the partner BC
in the Resale case would cause cross-subsidization.

Key helpers in `model/marketing/support_plan.go`:
- `isPartnerManagedCustomer(type)` — whitelist of billing config types whose
  support fee should be pushed via `SendReseller*` (CPPO + Resale, excluding
  MSP).
- `getResellerTenantAndBillingConfig(customer)` — resolves the partner tenant
  and billing config. CPPO uses `billing_configs.ManagedTenantID`; Resale
  uses `payment_methods.PaymentCode` (set at onboarding).
- `getCustomerUsageBase(source)` — sums customer daily cost, **excluding**
  the four support SKUs (circular-reference prevention).
- `getCustomerSupportFeeSpend(source)` — sums customer support-fee spend
  over a month, **excluding** `CommintmentPrice_SKU` (circular-reference
  prevention).

### Pay transfer skip

`MspHandlerImpl.Pay` (`handler/paymenthandler/impl/msp.go`) skips the four
support-plan units during the partner's invoice Pay transfer because they
were already pushed directly by the `SendReseller*` worker helpers (or, for
MSP, already on the partner's books from `MspCustomerSyncSupportPlan`).
Re-transferring via Pay would double-count. `ResaleHandlerImpl` embeds
`MspHandlerImpl`, inheriting this guard.

Design doc: `docs/billing-config-support-fee-design.md`.

## 21 workers (reconciliation / async)

`ar_table / billing_config / bills / binded_package / check_billing_accuracy / cost_quota / credits / discount / invoice / marketplace_payment / measure / no_traffic_dedicated_cluster_checker / plan / recharge_credit / reconciliation / refund / s3_bills_detail / spend_limit / support_plan / tenant_close`

Each worker is an independent goroutine, organised under sub-directories of `worker/`.

## AR table: estimate vs actual

`AREstimatedWorker` (runs on the 1st/2nd of each month) and `ARActualWorker` (runs on the 4th) have fundamentally different inputs:

- **Actual** — reads `user_unit_costs` rows written by `SendSupportPlanMetering`. This is the final, post-metering number.
- **Estimate** — **does NOT read `user_unit_costs` for the support fee**. It computes the support fee from `binded_support_plans` directly. The AR table runs on the 1st/2nd, and the metering pipeline may not have written any support rows yet for the month being estimated; tying the estimate to metering would silently drop support-only tenants from the AR table.

Estimate is intentionally contract-based and self-contained. The algorithm is locked in `docs/ar-table-estimate-support-design.md` and implemented at `model/payment/reconciliation/ar_table.go::EstimateSupportFeeForTenant`. The candidate set is locked in `QueryTenantBillingPairsForMonth` (which accepts `internal=true OR unit LIKE 'support-plan:%'`, and adds any tenant with an active `BindedSupportPlan` whose date window intersects the month). The two changes are paired — the candidate set fix without the estimate helper would still drop support-only tenants at the `InvoiceAmount <= 0` filter.

Estimate vs actual drift is reconciled downstream by the AR reconciliation pipeline; the AR table's estimate is best-effort, not a contractual number.

DO NOT regress this — the previous behaviour ("read `user_unit_costs.Support Plan`") was the source of the "support-only tenant silently dropped" bug, and the same regression will return if the cost-explorer call replaces the contract-based helper.

## External API surfaces

| Surface | Router file | Purpose |
|---------|-------------|---------|
| Main router | `api/router.go` | Internal main flow |
| OpenAPI | `api/router_openapi.go` | Public OpenAPI subset |
| MSP | `api/router_msp_openapi.go` | MSP open API |
| Partner | `api/router_partners_openapi.go` | Partner public API |
| Partner private | `api/router_partners_privateapi.go` | Partner private API |
| Inner | `api/router_inner_api.go` | Internal API |

## Conventions / things to know

- The business is very complex. Before changing a feature, confirm which handlers / models / workers it touches.
- For money calculations use `shopspring/decimal`. **Do not** use `float64`.
- Reconciliation runs in async workers. After a change, check whether the corresponding worker needs to be re-triggered.
- IDL depends on `core/core/core.proto` (the `ClusterService` enums: `DeployType`, `ComponentType`, `SKUType`, `ProfileStatus`, etc.).

## Marketplace push time window = detached by design (common convention, applies to all handlers)

**Scope**: every cloud-marketplace push handler under `handler/paymenthandler/impl/`.

| Handler | Push API | Time field | Value |
|---------|----------|------------|-------|
| `aliyun.go` | PushMeteringData | `StartTime` / `EndTime` | `timeutils.GetHourDate(timeutils.GetNow())`, aligned to the hour |
| `gcp.go:233` | ServiceControlAPI `Report` | `StartTime` / `EndTime` | `timeutils.GetHourDate(timeutils.GetNow())`, aligned to the hour |
| `azure.go:130` | Azure Marketplace Metering | `start` / `end` | `timeutils.GetHourDate(timeutils.GetNow())`, aligned to the hour |
| `aws.go:65,104` | Marketplace Metering `BatchMeterUsage` | `Timestamp` | `timeutils.GetNow()` |
| `aws_cppo.go` | (re-uses `SendInvoice`) | same as aws | same as aws |

### Core convention (every marketplace handler)

- **The push moment and the real invoice's billing window are detached by design.** The worker fires near the hour boundary and pushes "the window that just finished". A window slip caused by jitter across the hour (worker-pool queueing + `maxStartDuration=15s` random start + DB write latency) is **expected behaviour**, not a bug.
- Any PR that tries to "fix the window slip" (e.g. change `timeutils.GetNow()` to `invoices[0].StartDate` / `EndDate`) will break the business — reject in review.
- Do not "tidy up" by aligning the window calculation across handlers to `invoices[0].StartDate`. The five handlers' detached window is a **unified** design choice, not an accidental per-handler inconsistency.

### Known risk / pending: bug — aliyun missing `lastInvoiceSentDate` rate-limit guard

- GCP (`gcp.go:203`), Azure (`azure.go:106`), AWS (`aws.go:171`), AWS CPPO (`aws_cppo.go:188,228`) all have a 1-hour rate-limit guard at the `PayMultiInvoice` entry, implemented via `GetLastSendInvoiceDateByPaymentCode`.
- **`aliyun.go` does not.** The same `paymentCode` window can be pushed repeatedly, hitting Aliyun's 60-second-per-call limit (`Service.Flow.Control`) and marking the invoice as failed.
- **Status: pending, owner TBD.** Whether to add the guard, and at which layer (`invoice_model` vs the handler entry), needs business sign-off before any code change. Do not patch without authorisation.
- Discussion date: 2026-06-22.

## Measure-path SQL changes (discipline)

The measure path (the `CostQuery-BindedPackages` / `CostQuery-Discount`
/ `CostQuery-TieredBilling` stages in `model/billing/measure.go`) is
the highest-QPS DB code in this service. **Any SQL change in this
path must obey the following rules**:

1. **A feature flag is required**, default `false`, paired with a
   `*_use_*_total{...}` Prometheus counter so the on-call can
   read the rollout distribution directly. See
   `EnableUseDbSideBindedPackageFilter`
   + `measureUseDBSideBindedPackageTotal` for a reference
   implementation.
2. **The PR must include EXPLAIN-validation tests** run against
   prod-shape data (a few thousand rows), asserting that the query
   plan uses the new index and is not `type=ALL` (full table scan).
   See `TestGetEffectiveBindedPackages_QueryPlan_*`.
3. **The PR must include benchmarks** at 5k / 50k / 100k scale
   plus a 32-thread concurrent run. See
   `BenchmarkGetEffectiveBindedPackages*`.
4. **Do not push `(A = ? OR B = ?)` predicates into MySQL.** MySQL
   8's optimiser for this shape is a residual scan (filter every
   row) — not an index seek. To use an index, split the predicate
   into a UNION ALL of two branches.
5. **Do not push `EXISTS ... OR ...` shapes into MySQL as a
   subquery.** MySQL 8 will not lift the inner OR-predicate to a
   hash semi-join; it stays as a `DEPENDENT SUBQUERY` (N×M cost).
   Do this kind of filter in Go instead.

Origin of the rule: the 2026-06-16 prod-billing-aurora incident.
See `docs/postmortem-2026-06-16-prod-billing-aurora.md`.

## gormigrate v2 footgun (read first)

`go-gormigrate/gormigrate/v2` has a footgun on a fresh database:

- The first `gormigrate.Migrate()` call where `InitSchema` is
  non-nil and the migration table is empty runs ONLY initSchema,
  then bulk-inserts every per-record migration ID into the
  migration table as "already applied" — without actually
  running any of them.
- Consequence: a PR that adds a per-record migration appears to
  have run (the row is in `billing2_migrations`), but the schema
  was never actually changed. Many per-record migrations on
  prod have never been executed.

**Iron rules for writing migrations**:

1. **Every per-record migration must be self-idempotent.** Use
   `information_schema.columns` / `information_schema.statistics`
   to check whether the target column or index already exists;
   skip if so. MySQL 8 does not support `CREATE INDEX IF NOT
   EXISTS` (that is PostgreSQL syntax), so the information_schema
   check is the path.
2. **Do not play "wipe + re-run" tricks on the
   `billing2_migrations` table.** Multi-pod race conditions,
   partial DDL failures, and panic-triggered migrations all
   produce worse failure modes than letting the next pod start
   apply the migration automatically.
3. **Do not rely on gorm's `db.AutoMigrate` to create a STORED
   GENERATED column.** Write an explicit
   `ALTER TABLE ... ADD COLUMN ... GENERATED ALWAYS AS ... STORED`.
4. **Confirm gorm tag semantics before editing fields.** The `->`
   prefix makes `AutoMigrate` skip the column entirely (including
   any `index:...` clause on the same tag). If you only want a
   read-only field marker, do not use `->` — use a finer-grained
   hook or do it in the application layer.

If a new PR touches gormigrate, reviewer checklist:
1. Does the migration func have an `information_schema` guard?
2. How does it get re-run on warm prod? Is there an operator
   runbook (`docs/operator-migration-*.md` pattern)?
3. Has `EXPLAIN` been run against prod-shape data? Is the plan
   captured in an automated test
   (`binded_package_query_plan_test.go` pattern)?

## Measure-path SQL change checklist (for PR review)

For any PR that touches measure-path SQL, the reviewer must check:
- [ ] 1. Is there a feature flag, default `false`?
- [ ] 2. Is there a Prometheus counter so the on-call can monitor the rollout?
- [ ] 3. Has `EXPLAIN` been run on prod-shape data, and does the
        plan use the new index?
- [ ] 4. Are there benchmarks at 5k/50k/100k scale, plus a 32-thread
        concurrent run?
- [ ] 5. Are OR-predicates split with UNION/UNION ALL, or kept in Go?
- [ ] 6. Is there no `EXISTS ... OR EXISTS` shape (which becomes a
        dependent subquery)?
- [ ] 7. Can the change be rolled out gradually, and rolled back
        within 5 minutes?
- [ ] 8. For gormigrate changes, is there an operator runbook at
        `docs/operator-migration-*.md`?

## Docs

- `docs/billing-config-support-fee-design.md` — billing config types,
  partner-managed customer support fee flow, source/target split
  architecture, circular-reference prevention, Pay transfer skip.
- `docs/postmortem-2026-06-16-prod-billing-aurora.md` — root cause,
  fix, and action items from the 2026-06-16 incident.
- `docs/changelog-2026-06-22-binded-packages-measure-fix.md` —
  release note for this fix.

## FORCE INDEX on STORED GENERATED column indexes (rule)

MySQL (not Aurora-specific — this is a general optimiser
behaviour) refuses to pick an index whose leading or interior
column is a STORED GENERATED column. The optimiser treats the
STORED GENERATED column as a derived value and skips the
matching index entirely. To make the optimiser use that
index, the query must carry an explicit `FORCE INDEX
(<index_name>)` clause. In this codebase the term
"等价列" (equivalent column) covers the same thing: a
STORED GENERATED column that materialises the result of an
expression such as `JSON_UNQUOTE(JSON_EXTRACT(labels,
'$.clusterID'))` so the value can be indexed.

Three STORED GENERATED `cluster_id` columns ship in the
schema today, and each is backed by a code path that
FORCE INDEXes its index:

- `credit_types.cluster_id` (entity `UnitFilter.ClusterID`)
  - Index: `idx_ct_labels_cluster_id`
  - **No FORCE INDEX call site in the codebase.** The
    only one, `buildUnitFilterWhere`, was deleted in
    the 2026-06-22 measure-path PR rewrite. If a future
    query needs to filter on this column, the developer
    must (a) add a migration that creates both the
    column and the index, AND (b) write the query with
    `FORCE INDEX (idx_ct_labels_cluster_id)`.
- `user_unit_costs.cluster_id` (entity `UserUnitCost.ClusterID`)
  - Composite index: `(tenant_id, cost_time_stamp,
    status, cluster_id)`. FORCE INDEX in three places:
    - `model/billing/measure.go:983` — gorm:
      `db.GetDB().Set("gorm:query_option",
      "FORCE INDEX(tenant_timestamp_status_cluster)")`
    - `model/costexplorer/costexplorer.go:720` — helper
      constant `const forceClusterIndex = "force
      index(tenant_timestamp_status_cluster)"`, used
      by 5 raw-SQL call sites in the same package.
- `tiered_billings.cluster_id` (entity `TieredBilling.ClusterID`)
  - Index: `idx_cluster_id`. FORCE INDEX in:
    - `model/billing/tiered_billing/tiered_billing.go:111` —
      `db.GetDB().Table("tiered_billings FORCE INDEX
      (idx_cluster_id)").Where(...)`.

Self-check before shipping a query that filters on a
column backed by one of the indexes above:

1. **Does the index contain a STORED GENERATED column?**
   If yes, the query MUST carry `FORCE INDEX (...)`.
   Without it, MySQL will silently skip the index.
2. **Match one of the existing FORCE INDEX formats**:
   gorm `Set("gorm:query_option", ...)` /
   gorm `Table("... FORCE INDEX (...)")` /
   raw SQL string interpolation with `forceClusterIndex`
   from `model/costexplorer/...` (or a sibling constant
   defined next to it).
3. **Am I about to delete or rename a function /
   helper that contains a FORCE INDEX call?** Do not
   assume the FORCE is dead because the function has
   no other callers. A STORED GENERATED column index
   with no FORCE is a no-op, so deleting the only
   FORCE call site without a replacement is a silent
   performance regression.

## GORM Preload + UNION ALL re-fetch trade-off (rule)

When a code path materialises a slice of rows from
two or more GORM queries (typically after an OR-
rewrite that becomes a UNION ALL of two indexed
seeks, as in `GetEffectiveBindedPackages`) and then
needs to `Preload("X")` an M2M association, GORM
requires a third `Find` to anchor the M2M subqueries.
That third Find re-fetches every column of every
matched row by primary key — even though the rows
have already been materialised in the slice.

This is a known wart of GORM's design. It is **not
a bug** and should **not** be "fixed" on principle.
The current `GetEffectiveBindedPackages` design
emits three queries (branchA + branchB +
IN-Preload) and has been reviewed three times
in 2026-06 (PR review + self-review + comment
expansion) reaching the same conclusion each
time. The `binded_package.go` call site carries a
90-line `KNOWN WART — DELIBERATE, NOT A BUG` block
that documents the trade-off; read that block
before changing the structure.

The realistic alternatives, all of which were
considered and rejected:

1. **Per-branch Preload (4 queries instead of 3):**
   adds 1 round-trip. In prod cross-AZ each RTT is
   ~5 ms, so this is strictly slower than the
   current 3-query form. The byte-saving from
   skipping the redundant SELECT * is small (a few
   KB at typical tenant row counts); the latency
   cost of an extra RTT is much larger.
2. **Hand-roll the m2m load (2 queries, no third
   Find):** `SELECT ufbp WHERE bp_id IN (...) +
   SELECT credit_types WHERE id IN (...)`, attach
   by hand in Go. Bypasses GORM's reflection. The
   code becomes fragile against future entity-
   shape changes (a new field on BindedPackage
   will silently not be hydrated) and against
   GORM upgrades that change the M2M join table
   representation. Maintenance cost > the saving.
3. **Raw SQL with `gorm.Raw().Scan()` (1 query):**
   loses typed-entity unmarshalling, soft-delete-
   aware WHERE, and any future GORM hook. Not
   worth the saving.

For comparison: the pre-fix residual-scan cost on a
5k-row tenant was 5+ seconds. The 5 ms re-fetch
cost is 1000x cheaper. Optimising further is
below the noise floor.

**Self-check before "fixing" this kind of wart:**

1. Has the current design been reviewed at least
   once? If not, the wart is new and the trade-off
   needs to be made consciously, not inherited.
2. Is the third query's cost visible in the prod
   measure-path profile? If not, leave it.
3. If profiling on staging with real cross-AZ
   latency shows the third query accounts for more
   than ~10% of the total wall time, only then
   consider per-branch Preload (option 1 above) —
   and only after measuring, not on principle.

Follow-up task: measure the third-query RTT on
staging with real cross-AZ latency. The benchmark
infra at
`model/billing/tests/binded_package_bench_test.go`
already runs against a docker MySQL, but does not
simulate cross-AZ RTT. A second benchmark variant
with `db.SetConnMaxLifetime` + a deliberate slow-DNS
latency, or a live run on staging, would surface
the real cost.

## CI/CD

`.github/workflows/`: ci.yml / cd.yml / prod-cd.yaml / staging-cd.yaml / personal-dev-cd.yaml / api-preview.yaml

## Cross-repo dependencies

- **Called by**: Customer Portal / Admin Portal / every service with a billing touchpoint.
- **Calls**: AWS Marketplace / Stripe / Aliyun Subscription / Novu / Salesforce / Jira / JSM.
- **Downstream IDL**: `core-svc/idl/idl/core/core.proto` (enums) · `dataflow-service/idl/idl/dataflow/` (partial).
- **Deployment**: GitOps via the `app-delivery` repo (ArgoCD-driven; not part of this repo).