feat(billing): bootstrap billing service
This commit is contained in:
commit
5580f0ae1a
15
README.md
Normal file
15
README.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# billing-service
|
||||||
|
|
||||||
|
`billing-service` is the v1 minute-delta and replay-safe writer for the Cloud
|
||||||
|
Network Billing & Control Plane.
|
||||||
|
|
||||||
|
It pulls the latest normalized snapshot from `xray-exporter`, computes deltas
|
||||||
|
from cumulative counters, and writes idempotent usage and billing facts into the
|
||||||
|
existing `accounts.svc.plus` PostgreSQL schema.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `POST /v1/jobs/collect-and-rate`
|
||||||
|
- `POST /v1/jobs/reconcile`
|
||||||
|
- `GET /healthz`
|
||||||
|
- `GET /v1/status`
|
||||||
56
cmd/billing-service/main.go
Normal file
56
cmd/billing-service/main.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"billing-service/internal/config"
|
||||||
|
"billing-service/internal/exporter"
|
||||||
|
"billing-service/internal/httpapi"
|
||||||
|
"billing-service/internal/repository"
|
||||||
|
"billing-service/internal/service"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("pgx", cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
svc := service.New(
|
||||||
|
cfg,
|
||||||
|
exporter.NewClient(cfg.ExporterBaseURL),
|
||||||
|
repository.NewPostgres(db),
|
||||||
|
)
|
||||||
|
svc.Start(ctx)
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: cfg.ListenAddr,
|
||||||
|
Handler: httpapi.New(svc).Routes(),
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
_ = server.Shutdown(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("billing-service listening on %s", cfg.ListenAddr)
|
||||||
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
15
docker-compose.postgres.yml
Normal file
15
docker-compose.postgres.yml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: billing-service-test-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: billing_service_test
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
ports:
|
||||||
|
- "55432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d billing_service_test"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 20
|
||||||
17
go.mod
Normal file
17
go.mod
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
module billing-service
|
||||||
|
|
||||||
|
go 1.25.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/crypto v0.37.0 // indirect
|
||||||
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
|
golang.org/x/text v0.24.0 // indirect
|
||||||
|
)
|
||||||
30
go.sum
Normal file
30
go.sum
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
|
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
|
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
84
internal/config/config.go
Normal file
84
internal/config/config.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ExporterBaseURL string
|
||||||
|
DatabaseURL string
|
||||||
|
ListenAddr string
|
||||||
|
CollectInterval time.Duration
|
||||||
|
DefaultRegion string
|
||||||
|
SourceRevision string
|
||||||
|
PricePerByte float64
|
||||||
|
InitialIncludedQuotaBytes int64
|
||||||
|
InitialBalance float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (Config, error) {
|
||||||
|
cfg := Config{
|
||||||
|
ExporterBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("EXPORTER_BASE_URL")), "/"),
|
||||||
|
DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")),
|
||||||
|
ListenAddr: strings.TrimSpace(os.Getenv("LISTEN_ADDR")),
|
||||||
|
DefaultRegion: strings.TrimSpace(os.Getenv("DEFAULT_REGION")),
|
||||||
|
SourceRevision: strings.TrimSpace(os.Getenv("SOURCE_REVISION")),
|
||||||
|
}
|
||||||
|
if cfg.ListenAddr == "" {
|
||||||
|
cfg.ListenAddr = ":8081"
|
||||||
|
}
|
||||||
|
if cfg.SourceRevision == "" {
|
||||||
|
cfg.SourceRevision = "billing-service-v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ExporterBaseURL == "" {
|
||||||
|
return Config{}, fmt.Errorf("EXPORTER_BASE_URL is required")
|
||||||
|
}
|
||||||
|
if cfg.DatabaseURL == "" {
|
||||||
|
return Config{}, fmt.Errorf("DATABASE_URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
interval := strings.TrimSpace(os.Getenv("COLLECT_INTERVAL"))
|
||||||
|
if interval == "" {
|
||||||
|
cfg.CollectInterval = time.Minute
|
||||||
|
} else {
|
||||||
|
parsed, err := time.ParseDuration(interval)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("parse COLLECT_INTERVAL: %w", err)
|
||||||
|
}
|
||||||
|
cfg.CollectInterval = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.PricePerByte = parseFloatEnv("PRICE_PER_BYTE", 0)
|
||||||
|
cfg.InitialBalance = parseFloatEnv("INITIAL_BALANCE", 0)
|
||||||
|
cfg.InitialIncludedQuotaBytes = parseIntEnv("INITIAL_INCLUDED_QUOTA_BYTES", 0)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloatEnv(key string, fallback float64) float64 {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseFloat(raw, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntEnv(key string, fallback int64) int64 {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
54
internal/exporter/client.go
Normal file
54
internal/exporter/client.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package exporter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"billing-service/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(baseURL string) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||||||
|
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) FetchLatestSnapshot(ctx context.Context) (model.Snapshot, error) {
|
||||||
|
endpoint, err := url.JoinPath(c.baseURL, "/v1/snapshots/latest")
|
||||||
|
if err != nil {
|
||||||
|
return model.Snapshot{}, fmt.Errorf("build snapshot endpoint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return model.Snapshot{}, fmt.Errorf("build snapshot request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return model.Snapshot{}, fmt.Errorf("fetch snapshot: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return model.Snapshot{}, fmt.Errorf("fetch snapshot: unexpected status %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot model.Snapshot
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&snapshot); err != nil {
|
||||||
|
return model.Snapshot{}, fmt.Errorf("decode snapshot: %w", err)
|
||||||
|
}
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
76
internal/httpapi/handler.go
Normal file
76
internal/httpapi/handler.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"billing-service/internal/model"
|
||||||
|
"billing-service/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(svc *service.Service) *Handler {
|
||||||
|
return &Handler{service: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Routes() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/healthz", h.healthz)
|
||||||
|
mux.HandleFunc("/v1/status", h.status)
|
||||||
|
mux.HandleFunc("/v1/jobs/collect-and-rate", h.collectAndRate)
|
||||||
|
mux.HandleFunc("/v1/jobs/reconcile", h.reconcile)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) healthz(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ok, message := h.service.Health()
|
||||||
|
status := http.StatusOK
|
||||||
|
if !ok {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
writeJSON(w, status, map[string]any{
|
||||||
|
"status": map[bool]string{true: "ok", false: "degraded"}[ok],
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) status(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, h.service.Status())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) collectAndRate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.service.RunCollectAndRate(r.Context(), "collect-and-rate")
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) reconcile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.service.RunCollectAndRate(r.Context(), "reconcile")
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = model.JobResult{}
|
||||||
76
internal/model/types.go
Normal file
76
internal/model/types.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Sample struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
InboundTag string `json:"inbound_tag"`
|
||||||
|
UplinkBytesTotal int64 `json:"uplink_bytes_total"`
|
||||||
|
DownlinkBytesTotal int64 `json:"downlink_bytes_total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Snapshot struct {
|
||||||
|
CollectedAt time.Time `json:"collected_at"`
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
Env string `json:"env"`
|
||||||
|
Samples []Sample `json:"samples"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Checkpoint struct {
|
||||||
|
NodeID string
|
||||||
|
AccountUUID string
|
||||||
|
LastUplinkTotal int64
|
||||||
|
LastDownlinkTotal int64
|
||||||
|
LastSeenAt time.Time
|
||||||
|
XrayRevision string
|
||||||
|
ResetEpoch int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type MinuteBucket struct {
|
||||||
|
BucketStart time.Time
|
||||||
|
NodeID string
|
||||||
|
AccountUUID string
|
||||||
|
Region string
|
||||||
|
LineCode string
|
||||||
|
UplinkBytes int64
|
||||||
|
DownlinkBytes int64
|
||||||
|
TotalBytes int64
|
||||||
|
Multiplier float64
|
||||||
|
RatingStatus string
|
||||||
|
SourceRevision string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LedgerEntry struct {
|
||||||
|
ID string
|
||||||
|
AccountUUID string
|
||||||
|
BucketStart time.Time
|
||||||
|
BucketEnd time.Time
|
||||||
|
EntryType string
|
||||||
|
RatedBytes int64
|
||||||
|
AmountDelta float64
|
||||||
|
BalanceAfter float64
|
||||||
|
PricingRuleVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuotaState struct {
|
||||||
|
AccountUUID string
|
||||||
|
RemainingIncludedQuota int64
|
||||||
|
CurrentBalance float64
|
||||||
|
Arrears bool
|
||||||
|
ThrottleState string
|
||||||
|
SuspendState string
|
||||||
|
LastRatedBucketAt *time.Time
|
||||||
|
EffectiveAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobResult struct {
|
||||||
|
Job string `json:"job"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
FinishedAt time.Time `json:"finished_at"`
|
||||||
|
ProcessedSamples int `json:"processed_samples"`
|
||||||
|
WrittenMinutes int `json:"written_minutes"`
|
||||||
|
ReplayedMinutes int `json:"replayed_minutes"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
235
internal/repository/postgres.go
Normal file
235
internal/repository/postgres.go
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"billing-service/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Postgres struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPostgres(db *sql.DB) *Postgres {
|
||||||
|
return &Postgres{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) GetCheckpoint(ctx context.Context, nodeID, accountUUID string) (*model.Checkpoint, error) {
|
||||||
|
const query = `
|
||||||
|
SELECT node_id, account_uuid, last_uplink_total, last_downlink_total, last_seen_at, xray_revision, reset_epoch
|
||||||
|
FROM traffic_stat_checkpoints
|
||||||
|
WHERE node_id = $1 AND account_uuid = $2`
|
||||||
|
var checkpoint model.Checkpoint
|
||||||
|
err := p.db.QueryRowContext(ctx, query, nodeID, accountUUID).Scan(
|
||||||
|
&checkpoint.NodeID,
|
||||||
|
&checkpoint.AccountUUID,
|
||||||
|
&checkpoint.LastUplinkTotal,
|
||||||
|
&checkpoint.LastDownlinkTotal,
|
||||||
|
&checkpoint.LastSeenAt,
|
||||||
|
&checkpoint.XrayRevision,
|
||||||
|
&checkpoint.ResetEpoch,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &checkpoint, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) UpsertCheckpoint(ctx context.Context, checkpoint model.Checkpoint) error {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO traffic_stat_checkpoints (
|
||||||
|
node_id, account_uuid, last_uplink_total, last_downlink_total, last_seen_at, xray_revision, reset_epoch
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (node_id, account_uuid) DO UPDATE SET
|
||||||
|
last_uplink_total = EXCLUDED.last_uplink_total,
|
||||||
|
last_downlink_total = EXCLUDED.last_downlink_total,
|
||||||
|
last_seen_at = EXCLUDED.last_seen_at,
|
||||||
|
xray_revision = EXCLUDED.xray_revision,
|
||||||
|
reset_epoch = EXCLUDED.reset_epoch,
|
||||||
|
updated_at = now()`
|
||||||
|
_, err := p.db.ExecContext(ctx, query,
|
||||||
|
checkpoint.NodeID,
|
||||||
|
checkpoint.AccountUUID,
|
||||||
|
checkpoint.LastUplinkTotal,
|
||||||
|
checkpoint.LastDownlinkTotal,
|
||||||
|
checkpoint.LastSeenAt.UTC(),
|
||||||
|
checkpoint.XrayRevision,
|
||||||
|
checkpoint.ResetEpoch,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) UpsertMinuteBucket(ctx context.Context, bucket model.MinuteBucket) (bool, error) {
|
||||||
|
existed, err := p.minuteBucketExists(ctx, bucket)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO traffic_minute_buckets (
|
||||||
|
bucket_start, node_id, account_uuid, region, line_code, uplink_bytes, downlink_bytes, total_bytes, multiplier, rating_status, source_revision
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
ON CONFLICT (bucket_start, node_id, account_uuid, region, line_code) DO UPDATE SET
|
||||||
|
uplink_bytes = EXCLUDED.uplink_bytes,
|
||||||
|
downlink_bytes = EXCLUDED.downlink_bytes,
|
||||||
|
total_bytes = EXCLUDED.total_bytes,
|
||||||
|
multiplier = EXCLUDED.multiplier,
|
||||||
|
rating_status = EXCLUDED.rating_status,
|
||||||
|
source_revision = EXCLUDED.source_revision,
|
||||||
|
updated_at = now()`
|
||||||
|
_, err = p.db.ExecContext(ctx, query,
|
||||||
|
bucket.BucketStart.UTC(),
|
||||||
|
bucket.NodeID,
|
||||||
|
bucket.AccountUUID,
|
||||||
|
bucket.Region,
|
||||||
|
bucket.LineCode,
|
||||||
|
bucket.UplinkBytes,
|
||||||
|
bucket.DownlinkBytes,
|
||||||
|
bucket.TotalBytes,
|
||||||
|
bucket.Multiplier,
|
||||||
|
bucket.RatingStatus,
|
||||||
|
bucket.SourceRevision,
|
||||||
|
)
|
||||||
|
return existed, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) minuteBucketExists(ctx context.Context, bucket model.MinuteBucket) (bool, error) {
|
||||||
|
const query = `
|
||||||
|
SELECT 1
|
||||||
|
FROM traffic_minute_buckets
|
||||||
|
WHERE bucket_start = $1 AND node_id = $2 AND account_uuid = $3 AND region = $4 AND line_code = $5`
|
||||||
|
var marker int
|
||||||
|
err := p.db.QueryRowContext(ctx, query,
|
||||||
|
bucket.BucketStart.UTC(),
|
||||||
|
bucket.NodeID,
|
||||||
|
bucket.AccountUUID,
|
||||||
|
bucket.Region,
|
||||||
|
bucket.LineCode,
|
||||||
|
).Scan(&marker)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) UpsertLedger(ctx context.Context, entry model.LedgerEntry) (bool, error) {
|
||||||
|
existed, err := p.ledgerExists(ctx, entry.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO billing_ledger (
|
||||||
|
id, account_uuid, bucket_start, bucket_end, entry_type, rated_bytes, amount_delta, balance_after, pricing_rule_version
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
rated_bytes = EXCLUDED.rated_bytes,
|
||||||
|
amount_delta = EXCLUDED.amount_delta,
|
||||||
|
balance_after = EXCLUDED.balance_after,
|
||||||
|
pricing_rule_version = EXCLUDED.pricing_rule_version`
|
||||||
|
_, err = p.db.ExecContext(ctx, query,
|
||||||
|
entry.ID,
|
||||||
|
entry.AccountUUID,
|
||||||
|
entry.BucketStart.UTC(),
|
||||||
|
entry.BucketEnd.UTC(),
|
||||||
|
entry.EntryType,
|
||||||
|
entry.RatedBytes,
|
||||||
|
entry.AmountDelta,
|
||||||
|
entry.BalanceAfter,
|
||||||
|
entry.PricingRuleVersion,
|
||||||
|
)
|
||||||
|
return existed, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) ledgerExists(ctx context.Context, id string) (bool, error) {
|
||||||
|
var marker int
|
||||||
|
err := p.db.QueryRowContext(ctx, `SELECT 1 FROM billing_ledger WHERE id = $1`, id).Scan(&marker)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) GetQuotaState(ctx context.Context, accountUUID string) (*model.QuotaState, error) {
|
||||||
|
const query = `
|
||||||
|
SELECT account_uuid, remaining_included_quota, current_balance, arrears, throttle_state, suspend_state, last_rated_bucket_at, effective_at
|
||||||
|
FROM account_quota_states
|
||||||
|
WHERE account_uuid = $1`
|
||||||
|
var state model.QuotaState
|
||||||
|
var lastRated sql.NullTime
|
||||||
|
err := p.db.QueryRowContext(ctx, query, accountUUID).Scan(
|
||||||
|
&state.AccountUUID,
|
||||||
|
&state.RemainingIncludedQuota,
|
||||||
|
&state.CurrentBalance,
|
||||||
|
&state.Arrears,
|
||||||
|
&state.ThrottleState,
|
||||||
|
&state.SuspendState,
|
||||||
|
&lastRated,
|
||||||
|
&state.EffectiveAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastRated.Valid {
|
||||||
|
value := lastRated.Time
|
||||||
|
state.LastRatedBucketAt = &value
|
||||||
|
}
|
||||||
|
return &state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) UpsertQuotaState(ctx context.Context, state model.QuotaState) error {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO account_quota_states (
|
||||||
|
account_uuid, remaining_included_quota, current_balance, arrears, throttle_state, suspend_state, last_rated_bucket_at, effective_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (account_uuid) DO UPDATE SET
|
||||||
|
remaining_included_quota = EXCLUDED.remaining_included_quota,
|
||||||
|
current_balance = EXCLUDED.current_balance,
|
||||||
|
arrears = EXCLUDED.arrears,
|
||||||
|
throttle_state = EXCLUDED.throttle_state,
|
||||||
|
suspend_state = EXCLUDED.suspend_state,
|
||||||
|
last_rated_bucket_at = EXCLUDED.last_rated_bucket_at,
|
||||||
|
effective_at = EXCLUDED.effective_at,
|
||||||
|
updated_at = now()`
|
||||||
|
|
||||||
|
var lastRated interface{}
|
||||||
|
if state.LastRatedBucketAt != nil {
|
||||||
|
lastRated = state.LastRatedBucketAt.UTC()
|
||||||
|
}
|
||||||
|
_, err := p.db.ExecContext(ctx, query,
|
||||||
|
state.AccountUUID,
|
||||||
|
state.RemainingIncludedQuota,
|
||||||
|
state.CurrentBalance,
|
||||||
|
state.Arrears,
|
||||||
|
state.ThrottleState,
|
||||||
|
state.SuspendState,
|
||||||
|
lastRated,
|
||||||
|
state.EffectiveAt.UTC(),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Repository = (*Postgres)(nil)
|
||||||
|
|
||||||
|
func ensureUTC(ts time.Time) time.Time {
|
||||||
|
return ts.UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func unexpectedStatus(name string) error {
|
||||||
|
return fmt.Errorf("unexpected status for %s", name)
|
||||||
|
}
|
||||||
16
internal/repository/repository.go
Normal file
16
internal/repository/repository.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"billing-service/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
GetCheckpoint(ctx context.Context, nodeID, accountUUID string) (*model.Checkpoint, error)
|
||||||
|
UpsertCheckpoint(ctx context.Context, checkpoint model.Checkpoint) error
|
||||||
|
UpsertMinuteBucket(ctx context.Context, bucket model.MinuteBucket) (bool, error)
|
||||||
|
UpsertLedger(ctx context.Context, entry model.LedgerEntry) (bool, error)
|
||||||
|
GetQuotaState(ctx context.Context, accountUUID string) (*model.QuotaState, error)
|
||||||
|
UpsertQuotaState(ctx context.Context, state model.QuotaState) error
|
||||||
|
}
|
||||||
112
internal/service/postgres_acceptance_test.go
Normal file
112
internal/service/postgres_acceptance_test.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"billing-service/internal/config"
|
||||||
|
"billing-service/internal/model"
|
||||||
|
"billing-service/internal/repository"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostgresAcceptanceWritesAccountingTables(t *testing.T) {
|
||||||
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if databaseURL == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("pgx", databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
t.Fatalf("ping database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrapPath := filepath.Join("..", "..", "testdata", "postgres", "init.sql")
|
||||||
|
bootstrapSQL, err := os.ReadFile(bootstrapPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read bootstrap sql: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, string(bootstrapSQL)); err != nil {
|
||||||
|
t.Fatalf("apply bootstrap sql: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountUUID := "11111111-1111-1111-1111-111111111111"
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
DELETE FROM billing_ledger;
|
||||||
|
DELETE FROM traffic_minute_buckets;
|
||||||
|
DELETE FROM traffic_stat_checkpoints;
|
||||||
|
DELETE FROM account_quota_states;
|
||||||
|
DELETE FROM users;
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("reset tables: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO users (uuid, username, password, email, proxy_uuid)
|
||||||
|
VALUES ($1, 'billing-test', 'irrelevant', 'billing@example.com', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||||
|
`, accountUUID); err != nil {
|
||||||
|
t.Fatalf("seed user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := New(config.Config{
|
||||||
|
DefaultRegion: "",
|
||||||
|
SourceRevision: "billing-service-acceptance",
|
||||||
|
PricePerByte: 0.5,
|
||||||
|
InitialIncludedQuotaBytes: 1000,
|
||||||
|
InitialBalance: 0,
|
||||||
|
}, fakeSource{snapshot: model.Snapshot{
|
||||||
|
CollectedAt: time.Date(2026, 4, 8, 11, 0, 45, 0, time.UTC),
|
||||||
|
NodeID: "jp-node",
|
||||||
|
Env: "prod",
|
||||||
|
Samples: []model.Sample{{
|
||||||
|
UUID: accountUUID,
|
||||||
|
Email: "billing@example.com",
|
||||||
|
InboundTag: "premium",
|
||||||
|
UplinkBytesTotal: 100,
|
||||||
|
DownlinkBytesTotal: 50,
|
||||||
|
}},
|
||||||
|
}}, repository.NewPostgres(db))
|
||||||
|
|
||||||
|
result, err := svc.RunCollectAndRate(ctx, "collect-and-rate")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run collect-and-rate: %v", err)
|
||||||
|
}
|
||||||
|
if result.ProcessedSamples != 1 || result.WrittenMinutes != 1 {
|
||||||
|
t.Fatalf("unexpected result %#v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRowCount(t, db, "traffic_stat_checkpoints", 1)
|
||||||
|
assertRowCount(t, db, "traffic_minute_buckets", 1)
|
||||||
|
assertRowCount(t, db, "billing_ledger", 1)
|
||||||
|
assertRowCount(t, db, "account_quota_states", 1)
|
||||||
|
|
||||||
|
var totalBytes int64
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT total_bytes FROM traffic_minute_buckets LIMIT 1`).Scan(&totalBytes); err != nil {
|
||||||
|
t.Fatalf("query total_bytes: %v", err)
|
||||||
|
}
|
||||||
|
if totalBytes != 150 {
|
||||||
|
t.Fatalf("expected total_bytes 150, got %d", totalBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRowCount(t *testing.T, db *sql.DB, table string, want int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var got int
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&got); err != nil {
|
||||||
|
t.Fatalf("count rows in %s: %v", table, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("expected %d rows in %s, got %d", want, table, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
275
internal/service/service.go
Normal file
275
internal/service/service.go
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"billing-service/internal/config"
|
||||||
|
"billing-service/internal/model"
|
||||||
|
"billing-service/internal/repository"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type snapshotSource interface {
|
||||||
|
FetchLatestSnapshot(ctx context.Context) (model.Snapshot, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
cfg config.Config
|
||||||
|
source snapshotSource
|
||||||
|
repo repository.Repository
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
lastResult model.JobResult
|
||||||
|
lastOK bool
|
||||||
|
lastError string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.Config, source snapshotSource, repo repository.Repository) *Service {
|
||||||
|
return &Service{
|
||||||
|
cfg: cfg,
|
||||||
|
source: source,
|
||||||
|
repo: repo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Start(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
_, _ = s.RunCollectAndRate(ctx, "collect-and-rate")
|
||||||
|
ticker := time.NewTicker(s.cfg.CollectInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
_, _ = s.RunCollectAndRate(ctx, "collect-and-rate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RunCollectAndRate(ctx context.Context, job string) (model.JobResult, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
startedAt := time.Now().UTC()
|
||||||
|
result := model.JobResult{
|
||||||
|
Job: job,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
Status: "ok",
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := s.source.FetchLatestSnapshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "error"
|
||||||
|
result.Error = err.Error()
|
||||||
|
result.FinishedAt = time.Now().UTC()
|
||||||
|
s.record(result)
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sample := range snapshot.Samples {
|
||||||
|
if err := validateSample(sample); err != nil {
|
||||||
|
result.Status = "partial"
|
||||||
|
result.Error = joinError(result.Error, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
processed, err := s.processSample(ctx, snapshot, sample, &result)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "partial"
|
||||||
|
result.Error = joinError(result.Error, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if processed {
|
||||||
|
result.ProcessedSamples++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.FinishedAt = time.Now().UTC()
|
||||||
|
s.record(result)
|
||||||
|
if result.Status == "error" {
|
||||||
|
return result, errors.New(result.Error)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Status() model.JobResult {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.lastResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Health() (bool, string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.lastOK, s.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) processSample(ctx context.Context, snapshot model.Snapshot, sample model.Sample, result *model.JobResult) (bool, error) {
|
||||||
|
storageNodeID := composeStorageNodeID(snapshot.Env, snapshot.NodeID)
|
||||||
|
minuteStart := snapshot.CollectedAt.UTC().Truncate(time.Minute)
|
||||||
|
|
||||||
|
checkpoint, err := s.repo.GetCheckpoint(ctx, storageNodeID, sample.UUID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("get checkpoint %s: %w", sample.UUID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deltaUplink := sample.UplinkBytesTotal
|
||||||
|
deltaDownlink := sample.DownlinkBytesTotal
|
||||||
|
resetEpoch := int64(0)
|
||||||
|
if checkpoint != nil {
|
||||||
|
deltaUplink = sample.UplinkBytesTotal - checkpoint.LastUplinkTotal
|
||||||
|
deltaDownlink = sample.DownlinkBytesTotal - checkpoint.LastDownlinkTotal
|
||||||
|
resetEpoch = checkpoint.ResetEpoch
|
||||||
|
}
|
||||||
|
|
||||||
|
if deltaUplink < 0 || deltaDownlink < 0 {
|
||||||
|
resetEpoch++
|
||||||
|
err := s.repo.UpsertCheckpoint(ctx, model.Checkpoint{
|
||||||
|
NodeID: storageNodeID,
|
||||||
|
AccountUUID: sample.UUID,
|
||||||
|
LastUplinkTotal: sample.UplinkBytesTotal,
|
||||||
|
LastDownlinkTotal: sample.DownlinkBytesTotal,
|
||||||
|
LastSeenAt: snapshot.CollectedAt.UTC(),
|
||||||
|
XrayRevision: s.cfg.SourceRevision,
|
||||||
|
ResetEpoch: resetEpoch,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("upsert reset checkpoint %s: %w", sample.UUID, err)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBytes := deltaUplink + deltaDownlink
|
||||||
|
bucket := model.MinuteBucket{
|
||||||
|
BucketStart: minuteStart,
|
||||||
|
NodeID: storageNodeID,
|
||||||
|
AccountUUID: sample.UUID,
|
||||||
|
Region: s.cfg.DefaultRegion,
|
||||||
|
LineCode: strings.TrimSpace(sample.InboundTag),
|
||||||
|
UplinkBytes: deltaUplink,
|
||||||
|
DownlinkBytes: deltaDownlink,
|
||||||
|
TotalBytes: totalBytes,
|
||||||
|
Multiplier: 1.0,
|
||||||
|
RatingStatus: "rated",
|
||||||
|
SourceRevision: s.cfg.SourceRevision,
|
||||||
|
}
|
||||||
|
|
||||||
|
minuteExisted, err := s.repo.UpsertMinuteBucket(ctx, bucket)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("upsert minute bucket %s: %w", sample.UUID, err)
|
||||||
|
}
|
||||||
|
if minuteExisted {
|
||||||
|
result.ReplayedMinutes++
|
||||||
|
} else {
|
||||||
|
result.WrittenMinutes++
|
||||||
|
}
|
||||||
|
|
||||||
|
amountDelta := -float64(totalBytes) * s.cfg.PricePerByte
|
||||||
|
entry := model.LedgerEntry{
|
||||||
|
ID: deterministicLedgerID(bucket),
|
||||||
|
AccountUUID: sample.UUID,
|
||||||
|
BucketStart: minuteStart,
|
||||||
|
BucketEnd: minuteStart.Add(time.Minute),
|
||||||
|
EntryType: "traffic_charge",
|
||||||
|
RatedBytes: totalBytes,
|
||||||
|
AmountDelta: amountDelta,
|
||||||
|
PricingRuleVersion: s.cfg.SourceRevision,
|
||||||
|
}
|
||||||
|
|
||||||
|
quota, err := s.repo.GetQuotaState(ctx, sample.UUID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("get quota state %s: %w", sample.UUID, err)
|
||||||
|
}
|
||||||
|
if quota == nil {
|
||||||
|
quota = &model.QuotaState{
|
||||||
|
AccountUUID: sample.UUID,
|
||||||
|
RemainingIncludedQuota: s.cfg.InitialIncludedQuotaBytes,
|
||||||
|
CurrentBalance: s.cfg.InitialBalance,
|
||||||
|
ThrottleState: "normal",
|
||||||
|
SuspendState: "active",
|
||||||
|
EffectiveAt: snapshot.CollectedAt.UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.BalanceAfter = quota.CurrentBalance + amountDelta
|
||||||
|
|
||||||
|
ledgerExisted, err := s.repo.UpsertLedger(ctx, entry)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("upsert ledger %s: %w", sample.UUID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ledgerExisted {
|
||||||
|
remainingQuota := quota.RemainingIncludedQuota - totalBytes
|
||||||
|
if remainingQuota < 0 {
|
||||||
|
remainingQuota = 0
|
||||||
|
}
|
||||||
|
quota.RemainingIncludedQuota = remainingQuota
|
||||||
|
quota.CurrentBalance = entry.BalanceAfter
|
||||||
|
quota.EffectiveAt = snapshot.CollectedAt.UTC()
|
||||||
|
lastRated := minuteStart
|
||||||
|
quota.LastRatedBucketAt = &lastRated
|
||||||
|
if err := s.repo.UpsertQuotaState(ctx, *quota); err != nil {
|
||||||
|
return false, fmt.Errorf("upsert quota state %s: %w", sample.UUID, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.ReplayedMinutes++
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.UpsertCheckpoint(ctx, model.Checkpoint{
|
||||||
|
NodeID: storageNodeID,
|
||||||
|
AccountUUID: sample.UUID,
|
||||||
|
LastUplinkTotal: sample.UplinkBytesTotal,
|
||||||
|
LastDownlinkTotal: sample.DownlinkBytesTotal,
|
||||||
|
LastSeenAt: snapshot.CollectedAt.UTC(),
|
||||||
|
XrayRevision: s.cfg.SourceRevision,
|
||||||
|
ResetEpoch: resetEpoch,
|
||||||
|
}); err != nil {
|
||||||
|
return false, fmt.Errorf("upsert checkpoint %s: %w", sample.UUID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSample(sample model.Sample) error {
|
||||||
|
if strings.TrimSpace(sample.UUID) == "" {
|
||||||
|
return fmt.Errorf("sample uuid is required")
|
||||||
|
}
|
||||||
|
if _, err := uuid.Parse(strings.TrimSpace(sample.UUID)); err != nil {
|
||||||
|
return fmt.Errorf("sample uuid %q is not a valid UUID", sample.UUID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deterministicLedgerID(bucket model.MinuteBucket) string {
|
||||||
|
key := fmt.Sprintf("%s|%s|%s|%s|%s", bucket.BucketStart.UTC().Format(time.RFC3339), bucket.NodeID, bucket.AccountUUID, bucket.Region, bucket.LineCode)
|
||||||
|
return uuid.NewSHA1(uuid.NameSpaceOID, []byte(key)).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func composeStorageNodeID(env, nodeID string) string {
|
||||||
|
env = strings.TrimSpace(env)
|
||||||
|
nodeID = strings.TrimSpace(nodeID)
|
||||||
|
if env == "" {
|
||||||
|
return nodeID
|
||||||
|
}
|
||||||
|
return env + ":" + nodeID
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinError(existing, next string) string {
|
||||||
|
if existing == "" {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return existing + "; " + next
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) record(result model.JobResult) {
|
||||||
|
s.lastResult = result
|
||||||
|
s.lastError = result.Error
|
||||||
|
s.lastOK = result.Status != "error"
|
||||||
|
}
|
||||||
302
internal/service/service_test.go
Normal file
302
internal/service/service_test.go
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"billing-service/internal/config"
|
||||||
|
"billing-service/internal/model"
|
||||||
|
"billing-service/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeSource struct {
|
||||||
|
snapshot model.Snapshot
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeSource) FetchLatestSnapshot(context.Context) (model.Snapshot, error) {
|
||||||
|
return f.snapshot, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type memoryRepo struct {
|
||||||
|
checkpoints map[string]model.Checkpoint
|
||||||
|
buckets map[string]model.MinuteBucket
|
||||||
|
ledgers map[string]model.LedgerEntry
|
||||||
|
quotas map[string]model.QuotaState
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemoryRepo() *memoryRepo {
|
||||||
|
return &memoryRepo{
|
||||||
|
checkpoints: map[string]model.Checkpoint{},
|
||||||
|
buckets: map[string]model.MinuteBucket{},
|
||||||
|
ledgers: map[string]model.LedgerEntry{},
|
||||||
|
quotas: map[string]model.QuotaState{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointKey(nodeID, accountUUID string) string {
|
||||||
|
return nodeID + "\x00" + accountUUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func bucketKey(bucket model.MinuteBucket) string {
|
||||||
|
return bucket.BucketStart.UTC().Format(time.RFC3339) + "\x00" + bucket.NodeID + "\x00" + bucket.AccountUUID + "\x00" + bucket.Region + "\x00" + bucket.LineCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryRepo) GetCheckpoint(ctx context.Context, nodeID, accountUUID string) (*model.Checkpoint, error) {
|
||||||
|
if checkpoint, ok := m.checkpoints[checkpointKey(nodeID, accountUUID)]; ok {
|
||||||
|
copy := checkpoint
|
||||||
|
return ©, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryRepo) UpsertCheckpoint(ctx context.Context, checkpoint model.Checkpoint) error {
|
||||||
|
m.checkpoints[checkpointKey(checkpoint.NodeID, checkpoint.AccountUUID)] = checkpoint
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryRepo) UpsertMinuteBucket(ctx context.Context, bucket model.MinuteBucket) (bool, error) {
|
||||||
|
key := bucketKey(bucket)
|
||||||
|
_, existed := m.buckets[key]
|
||||||
|
m.buckets[key] = bucket
|
||||||
|
return existed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryRepo) UpsertLedger(ctx context.Context, entry model.LedgerEntry) (bool, error) {
|
||||||
|
_, existed := m.ledgers[entry.ID]
|
||||||
|
m.ledgers[entry.ID] = entry
|
||||||
|
return existed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryRepo) GetQuotaState(ctx context.Context, accountUUID string) (*model.QuotaState, error) {
|
||||||
|
if quota, ok := m.quotas[accountUUID]; ok {
|
||||||
|
copy := quota
|
||||||
|
return ©, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryRepo) UpsertQuotaState(ctx context.Context, state model.QuotaState) error {
|
||||||
|
m.quotas[state.AccountUUID] = state
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ repository.Repository = (*memoryRepo)(nil)
|
||||||
|
|
||||||
|
func baseConfig() config.Config {
|
||||||
|
return config.Config{
|
||||||
|
DefaultRegion: "",
|
||||||
|
SourceRevision: "billing-service-v1",
|
||||||
|
PricePerByte: 0.5,
|
||||||
|
InitialIncludedQuotaBytes: 1000,
|
||||||
|
InitialBalance: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeltaCalculationAndQuotaUpdate(t *testing.T) {
|
||||||
|
repo := newMemoryRepo()
|
||||||
|
svc := New(baseConfig(), fakeSource{snapshot: model.Snapshot{
|
||||||
|
CollectedAt: time.Date(2026, 4, 8, 10, 30, 15, 0, time.UTC),
|
||||||
|
NodeID: "jp-node",
|
||||||
|
Env: "prod",
|
||||||
|
Samples: []model.Sample{{
|
||||||
|
UUID: "11111111-1111-1111-1111-111111111111",
|
||||||
|
Email: "user@example.com",
|
||||||
|
InboundTag: "premium",
|
||||||
|
UplinkBytesTotal: 100,
|
||||||
|
DownlinkBytesTotal: 50,
|
||||||
|
}},
|
||||||
|
}}, repo)
|
||||||
|
|
||||||
|
result, err := svc.RunCollectAndRate(context.Background(), "collect-and-rate")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run job: %v", err)
|
||||||
|
}
|
||||||
|
if result.ProcessedSamples != 1 || result.WrittenMinutes != 1 {
|
||||||
|
t.Fatalf("unexpected result %#v", result)
|
||||||
|
}
|
||||||
|
quota := repo.quotas["11111111-1111-1111-1111-111111111111"]
|
||||||
|
if quota.RemainingIncludedQuota != 850 {
|
||||||
|
t.Fatalf("expected remaining quota 850, got %d", quota.RemainingIncludedQuota)
|
||||||
|
}
|
||||||
|
if quota.CurrentBalance != -75 {
|
||||||
|
t.Fatalf("expected current balance -75, got %v", quota.CurrentBalance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateMinuteIsReplaySafe(t *testing.T) {
|
||||||
|
repo := newMemoryRepo()
|
||||||
|
snapshot := model.Snapshot{
|
||||||
|
CollectedAt: time.Date(2026, 4, 8, 10, 30, 30, 0, time.UTC),
|
||||||
|
NodeID: "jp-node",
|
||||||
|
Env: "prod",
|
||||||
|
Samples: []model.Sample{{
|
||||||
|
UUID: "11111111-1111-1111-1111-111111111111",
|
||||||
|
Email: "user@example.com",
|
||||||
|
InboundTag: "premium",
|
||||||
|
UplinkBytesTotal: 100,
|
||||||
|
DownlinkBytesTotal: 50,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
svc := New(baseConfig(), fakeSource{snapshot: snapshot}, repo)
|
||||||
|
|
||||||
|
if _, err := svc.RunCollectAndRate(context.Background(), "collect-and-rate"); err != nil {
|
||||||
|
t.Fatalf("first run: %v", err)
|
||||||
|
}
|
||||||
|
result, err := svc.RunCollectAndRate(context.Background(), "collect-and-rate")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second run: %v", err)
|
||||||
|
}
|
||||||
|
if result.ReplayedMinutes == 0 {
|
||||||
|
t.Fatalf("expected replayed minutes, got %#v", result)
|
||||||
|
}
|
||||||
|
if len(repo.ledgers) != 1 {
|
||||||
|
t.Fatalf("expected 1 ledger entry, got %d", len(repo.ledgers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNegativeDeltaProtection(t *testing.T) {
|
||||||
|
repo := newMemoryRepo()
|
||||||
|
cfg := baseConfig()
|
||||||
|
accountUUID := "11111111-1111-1111-1111-111111111111"
|
||||||
|
nodeKey := composeStorageNodeID("prod", "jp-node")
|
||||||
|
repo.checkpoints[checkpointKey(nodeKey, accountUUID)] = model.Checkpoint{
|
||||||
|
NodeID: nodeKey,
|
||||||
|
AccountUUID: accountUUID,
|
||||||
|
LastUplinkTotal: 200,
|
||||||
|
LastDownlinkTotal: 200,
|
||||||
|
LastSeenAt: time.Now().UTC(),
|
||||||
|
XrayRevision: "prev",
|
||||||
|
ResetEpoch: 0,
|
||||||
|
}
|
||||||
|
svc := New(cfg, fakeSource{snapshot: model.Snapshot{
|
||||||
|
CollectedAt: time.Date(2026, 4, 8, 10, 31, 0, 0, time.UTC),
|
||||||
|
NodeID: "jp-node",
|
||||||
|
Env: "prod",
|
||||||
|
Samples: []model.Sample{{
|
||||||
|
UUID: accountUUID,
|
||||||
|
InboundTag: "premium",
|
||||||
|
UplinkBytesTotal: 10,
|
||||||
|
DownlinkBytesTotal: 20,
|
||||||
|
}},
|
||||||
|
}}, repo)
|
||||||
|
|
||||||
|
result, err := svc.RunCollectAndRate(context.Background(), "collect-and-rate")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run job: %v", err)
|
||||||
|
}
|
||||||
|
if result.ProcessedSamples != 0 {
|
||||||
|
t.Fatalf("expected negative delta sample to be skipped, got %#v", result)
|
||||||
|
}
|
||||||
|
if len(repo.buckets) != 0 || len(repo.ledgers) != 0 {
|
||||||
|
t.Fatalf("expected no writes on negative delta")
|
||||||
|
}
|
||||||
|
if repo.checkpoints[checkpointKey(nodeKey, accountUUID)].ResetEpoch != 1 {
|
||||||
|
t.Fatalf("expected reset epoch increment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartRecoveryFromCheckpoint(t *testing.T) {
|
||||||
|
repo := newMemoryRepo()
|
||||||
|
accountUUID := "11111111-1111-1111-1111-111111111111"
|
||||||
|
nodeKey := composeStorageNodeID("prod", "jp-node")
|
||||||
|
repo.checkpoints[checkpointKey(nodeKey, accountUUID)] = model.Checkpoint{
|
||||||
|
NodeID: nodeKey,
|
||||||
|
AccountUUID: accountUUID,
|
||||||
|
LastUplinkTotal: 100,
|
||||||
|
LastDownlinkTotal: 100,
|
||||||
|
LastSeenAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
svc := New(baseConfig(), fakeSource{snapshot: model.Snapshot{
|
||||||
|
CollectedAt: time.Date(2026, 4, 8, 10, 32, 0, 0, time.UTC),
|
||||||
|
NodeID: "jp-node",
|
||||||
|
Env: "prod",
|
||||||
|
Samples: []model.Sample{{
|
||||||
|
UUID: accountUUID,
|
||||||
|
InboundTag: "premium",
|
||||||
|
UplinkBytesTotal: 130,
|
||||||
|
DownlinkBytesTotal: 140,
|
||||||
|
}},
|
||||||
|
}}, repo)
|
||||||
|
|
||||||
|
result, err := svc.RunCollectAndRate(context.Background(), "collect-and-rate")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run job: %v", err)
|
||||||
|
}
|
||||||
|
if result.ProcessedSamples != 1 || result.WrittenMinutes != 1 {
|
||||||
|
t.Fatalf("unexpected result %#v", result)
|
||||||
|
}
|
||||||
|
bucket := repo.buckets[bucketKey(model.MinuteBucket{
|
||||||
|
BucketStart: time.Date(2026, 4, 8, 10, 32, 0, 0, time.UTC),
|
||||||
|
NodeID: nodeKey,
|
||||||
|
AccountUUID: accountUUID,
|
||||||
|
Region: "",
|
||||||
|
LineCode: "premium",
|
||||||
|
})]
|
||||||
|
if bucket.TotalBytes != 70 {
|
||||||
|
t.Fatalf("expected recovered delta 70, got %d", bucket.TotalBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiEnvIsolation(t *testing.T) {
|
||||||
|
repo := newMemoryRepo()
|
||||||
|
accountUUID := "11111111-1111-1111-1111-111111111111"
|
||||||
|
cfg := baseConfig()
|
||||||
|
|
||||||
|
prodSvc := New(cfg, fakeSource{snapshot: model.Snapshot{
|
||||||
|
CollectedAt: time.Date(2026, 4, 8, 10, 33, 0, 0, time.UTC),
|
||||||
|
NodeID: "jp-node",
|
||||||
|
Env: "prod",
|
||||||
|
Samples: []model.Sample{{UUID: accountUUID, InboundTag: "premium", UplinkBytesTotal: 10, DownlinkBytesTotal: 10}},
|
||||||
|
}}, repo)
|
||||||
|
previewSvc := New(cfg, fakeSource{snapshot: model.Snapshot{
|
||||||
|
CollectedAt: time.Date(2026, 4, 8, 10, 33, 0, 0, time.UTC),
|
||||||
|
NodeID: "jp-node",
|
||||||
|
Env: "preview",
|
||||||
|
Samples: []model.Sample{{UUID: accountUUID, InboundTag: "premium", UplinkBytesTotal: 10, DownlinkBytesTotal: 10}},
|
||||||
|
}}, repo)
|
||||||
|
|
||||||
|
if _, err := prodSvc.RunCollectAndRate(context.Background(), "collect-and-rate"); err != nil {
|
||||||
|
t.Fatalf("prod run: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := previewSvc.RunCollectAndRate(context.Background(), "collect-and-rate"); err != nil {
|
||||||
|
t.Fatalf("preview run: %v", err)
|
||||||
|
}
|
||||||
|
if len(repo.buckets) != 2 {
|
||||||
|
t.Fatalf("expected isolated buckets per env, got %d", len(repo.buckets))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLateMinuteReconcileUsesSameMinuteKey(t *testing.T) {
|
||||||
|
repo := newMemoryRepo()
|
||||||
|
accountUUID := "11111111-1111-1111-1111-111111111111"
|
||||||
|
cfg := baseConfig()
|
||||||
|
collectedAt := time.Date(2026, 4, 8, 10, 34, 50, 0, time.UTC)
|
||||||
|
snapshot := model.Snapshot{
|
||||||
|
CollectedAt: collectedAt,
|
||||||
|
NodeID: "jp-node",
|
||||||
|
Env: "prod",
|
||||||
|
Samples: []model.Sample{{
|
||||||
|
UUID: accountUUID,
|
||||||
|
InboundTag: "premium",
|
||||||
|
UplinkBytesTotal: 20,
|
||||||
|
DownlinkBytesTotal: 20,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
svc := New(cfg, fakeSource{snapshot: snapshot}, repo)
|
||||||
|
|
||||||
|
if _, err := svc.RunCollectAndRate(context.Background(), "collect-and-rate"); err != nil {
|
||||||
|
t.Fatalf("first run: %v", err)
|
||||||
|
}
|
||||||
|
result, err := svc.RunCollectAndRate(context.Background(), "reconcile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reconcile run: %v", err)
|
||||||
|
}
|
||||||
|
if result.ReplayedMinutes == 0 {
|
||||||
|
t.Fatalf("expected reconcile to report replayed minute, got %#v", result)
|
||||||
|
}
|
||||||
|
if len(repo.buckets) != 1 {
|
||||||
|
t.Fatalf("expected single logical minute bucket, got %d", len(repo.buckets))
|
||||||
|
}
|
||||||
|
}
|
||||||
72
testdata/postgres/init.sql
vendored
Normal file
72
testdata/postgres/init.sql
vendored
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS public.users (
|
||||||
|
uuid UUID PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
level INTEGER NOT NULL DEFAULT 20,
|
||||||
|
groups JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
origin_node TEXT NOT NULL DEFAULT 'local',
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
proxy_uuid UUID NOT NULL,
|
||||||
|
CONSTRAINT users_email_optional_ck CHECK (email IS NULL OR length(email) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.traffic_stat_checkpoints (
|
||||||
|
node_id TEXT NOT NULL,
|
||||||
|
account_uuid UUID NOT NULL REFERENCES public.users(uuid) ON DELETE CASCADE,
|
||||||
|
last_uplink_total BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_downlink_total BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL,
|
||||||
|
xray_revision TEXT NOT NULL DEFAULT '',
|
||||||
|
reset_epoch BIGINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (node_id, account_uuid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.traffic_minute_buckets (
|
||||||
|
bucket_start TIMESTAMPTZ NOT NULL,
|
||||||
|
node_id TEXT NOT NULL,
|
||||||
|
account_uuid UUID NOT NULL REFERENCES public.users(uuid) ON DELETE CASCADE,
|
||||||
|
region TEXT NOT NULL DEFAULT '',
|
||||||
|
line_code TEXT NOT NULL DEFAULT '',
|
||||||
|
uplink_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
downlink_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
multiplier DOUBLE PRECISION NOT NULL DEFAULT 1.0,
|
||||||
|
rating_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
source_revision TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (bucket_start, node_id, account_uuid, region, line_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.billing_ledger (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
account_uuid UUID NOT NULL REFERENCES public.users(uuid) ON DELETE CASCADE,
|
||||||
|
bucket_start TIMESTAMPTZ NOT NULL,
|
||||||
|
bucket_end TIMESTAMPTZ NOT NULL,
|
||||||
|
entry_type TEXT NOT NULL,
|
||||||
|
rated_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
amount_delta DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
balance_after DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
pricing_rule_version TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.account_quota_states (
|
||||||
|
account_uuid UUID PRIMARY KEY REFERENCES public.users(uuid) ON DELETE CASCADE,
|
||||||
|
remaining_included_quota BIGINT NOT NULL DEFAULT 0,
|
||||||
|
current_balance DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
arrears BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
throttle_state TEXT NOT NULL DEFAULT 'normal',
|
||||||
|
suspend_state TEXT NOT NULL DEFAULT 'active',
|
||||||
|
last_rated_bucket_at TIMESTAMPTZ NULL,
|
||||||
|
effective_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue
Block a user