Files
notify/internal/metering/metering.go
zeekay fe91b1cb27 notify: wire async send via hanzoai/tasks per CONTRACT.md
POST /v1/notify/send now defaults to durable async dispatch via
hanzoai/tasks (NotifySendWorkflow, queue notify-send) and returns 202
Accepted with {message_id, task_id, status:"queued"}. ?sync=true keeps
the inline path and returns 200 with the terminal result.

The router fails closed if async is requested but the Dispatcher is
nil or not Started (503) — no silent sync fallback, matching
hanzoai/tasks CONTRACT.md §3 ("production MUST set TASKS_ADDR").

Worker lifecycle moves out of routes/ into cmd/notifyd so the routes
package depends only on the new tasks.Dispatcher interface; tests stub
it directly. Tasks namespace defaults to "default" per CONTRACT.md §6
(single shared worker for all tenants in this PR; per-org namespacing
is the next step once liquidity tenant traffic scales).

A handful of internal helpers narrow from *base.Base to the smaller
core.App interface (Activities, metering.Write/Aggregate, tenant.New,
event.Resolve, template.LoadPublished, mountSend) so tests can drive
them with tests.NewTestApp without booting a full daemon.

Tests:
- internal/tasks/activities_test.go — Deliver idempotency
  short-circuits on status=sent and status=failed plus nil-receiver
  guard.
- internal/routes/send_test.go — sync 200 / async 202 happy paths +
  503-on-missing-dispatcher + 503-on-unstarted-dispatcher +
  500-on-dispatch-error + 401-on-missing-X-Org-Id.

References hanzoai/tasks#3.
2026-06-01 21:08:16 -07:00

128 lines
4.2 KiB
Go

// Package metering writes per-send ledger rows and exposes a query for
// the billing surface. Rows land in the `meter` collection synchronously
// (one INSERT per send); the optional rollup goroutine sweeps them
// daily into S3 prefixed by tenant.
//
// Two cost numbers per row:
//
// vendor_cost_micros — what the provider charged Hanzo (Plivo, Twilio, …)
// retail_price_micros — what Hanzo charges the tenant
//
// The markup factor lives in the catalog, not here — metering only
// persists the snapshot the caller passes in.
package metering
import (
"errors"
"fmt"
"github.com/hanzoai/base/core"
"github.com/hanzoai/dbx"
"github.com/hanzoai/notify/internal/schema"
"github.com/hanzoai/notify/pkg/types"
)
// Record is the input shape callers pass to Write — a flat, pre-priced
// row ready for insertion.
type Record struct {
TenantSlug string
Event string
Provider string
Channel types.Channel
Units int
VendorCostMicros int64
RetailPriceMicros int64
MessageID string
}
// Write inserts one meter row. Returns the persisted record id or an
// error. Idempotency must be handled at the caller layer — the meter
// is append-only and does not dedup.
func Write(app core.App, rec Record) (string, error) {
if rec.TenantSlug == "" || rec.Provider == "" || rec.Channel == "" {
return "", errors.New("metering: tenant, provider, channel are required")
}
col, err := app.FindCollectionByNameOrId(schema.Meter)
if err != nil {
return "", fmt.Errorf("metering: find collection: %w", err)
}
row := core.NewRecord(col)
row.Set("tenant", rec.TenantSlug)
row.Set("event", rec.Event)
row.Set("provider", rec.Provider)
row.Set("channel", string(rec.Channel))
row.Set("units", rec.Units)
row.Set("vendor_cost_micros", rec.VendorCostMicros)
row.Set("retail_price_micros", rec.RetailPriceMicros)
row.Set("message_id", rec.MessageID)
if err := app.Save(row); err != nil {
return "", fmt.Errorf("metering: save: %w", err)
}
return row.Id, nil
}
// Summary aggregates meter rows for one tenant in a time window. Used
// by GET /v1/notify/metering. The from/to params are ISO-8601 strings;
// empty means open-ended.
type Summary struct {
TenantSlug string `json:"tenant_slug"`
TotalUnits int `json:"total_units"`
TotalVendorMicros int64 `json:"total_vendor_cost_micros"`
TotalRetailMicros int64 `json:"total_retail_price_micros"`
ByProviderChannel map[string]ProviderRoll `json:"by_provider_channel"`
}
// ProviderRoll is the per-(provider,channel) breakdown.
type ProviderRoll struct {
Units int `json:"units"`
VendorMicros int64 `json:"vendor_cost_micros"`
RetailMicros int64 `json:"retail_price_micros"`
}
// Aggregate scans meter rows for the tenant and returns a roll-up. It
// loads up to maxRows records — callers that need windowed billing
// should pass a tight (from,to) and a high cap; the daily-rollup job
// uses streaming reads via the underlying dbx connection directly.
func Aggregate(app core.App, tenant, from, to string, maxRows int) (*Summary, error) {
if tenant == "" {
return nil, errors.New("metering: tenant is required")
}
if maxRows <= 0 {
maxRows = 5000
}
filter := "tenant = {:tenant}"
params := dbx.Params{"tenant": tenant}
if from != "" {
filter += " && sent_at >= {:from}"
params["from"] = from
}
if to != "" {
filter += " && sent_at <= {:to}"
params["to"] = to
}
rows, err := app.FindRecordsByFilter(schema.Meter, filter, "-sent_at", maxRows, 0, params)
if err != nil {
return nil, fmt.Errorf("metering: aggregate: %w", err)
}
out := &Summary{
TenantSlug: tenant,
ByProviderChannel: map[string]ProviderRoll{},
}
for _, r := range rows {
key := r.GetString("provider") + "/" + r.GetString("channel")
roll := out.ByProviderChannel[key]
units := r.GetInt("units")
vendor := int64(r.GetInt("vendor_cost_micros"))
retail := int64(r.GetInt("retail_price_micros"))
roll.Units += units
roll.VendorMicros += vendor
roll.RetailMicros += retail
out.ByProviderChannel[key] = roll
out.TotalUnits += units
out.TotalVendorMicros += vendor
out.TotalRetailMicros += retail
}
return out, nil
}