Documentation
¶
Overview ¶
Package billing implements Arkfile's storage-usage meter and credits ledger.
It is the single, definitive implementation of docs/wip/storage-credits-v2.md. All balances and amounts are denominated in microcents (1 USD = 100 cents = 100,000,000 microcents). Balances are signed: a user who overdraws their balance simply goes negative; there is no separate deficit column.
Public surface:
- Rate, ResolveRate, SetCachedRate: rate resolution from billing_settings, with an atomic.Pointer cache that admin set-price calls swap directly.
- TickUser, TickAllActiveUsers: per-hour metering. Writes the per-user accumulator row only when there is a billable charge.
- SweepAllUsers: per-day settlement. Drains accumulator into user_credits and writes one 'usage' transaction per user.
- Scheduler: wall-clock-aligned ticker loop wired into main.go. Injectable time source for deterministic tests.
- GiftCredits: admin-initiated positive balance adjustment, written as a typed 'gift' transaction so the audit log distinguishes operator gifts from future paid top-ups.
Privacy posture: the meter never logs per-tick activity; only the daily sweep writes audit rows. Settlement metadata deliberately excludes per-day storage time-series fields (see §3.5 of the design doc).
Index ¶
- Constants
- func GiftCredits(db *sql.DB, username string, amountUSDMicrocents int64, ...) (*models.CreditTransaction, error)
- func ResetCachedRateForTest()
- func SeedCustomerPriceIfMissing(db *sql.DB, cfg config.BillingConfig) (string, error)
- func SetCachedRate(r *Rate)
- func TickAllActiveUsers(db *sql.DB, rate *Rate, now time.Time, cfg config.BillingConfig) (count int, errCount int, err error)
- func TickUser(db *sql.DB, username string, rate *Rate, now time.Time, ...) error
- type Rate
- type Scheduler
- type SettlementMetadata
- type SweepSummary
Constants ¶
const BillingSettingsKeyCustomerPrice = "customer_price_usd_per_tb_per_month"
BillingSettingsKeyCustomerPrice is the row key in billing_settings.
const HardcodedSafetyPriceUSDPerTBPerMonth = "10.00"
HardcodedSafetyPriceUSDPerTBPerMonth is the last-resort fallback used when neither billing_settings nor the env var contain a parseable price. The meter logs ERROR and continues running so it never silently stops billing.
Variables ¶
This section is empty.
Functions ¶
func GiftCredits ¶
func GiftCredits(db *sql.DB, username string, amountUSDMicrocents int64, reason, adminUsername string) (*models.CreditTransaction, error)
GiftCredits adds positive microcent credit to a user's balance and records it as a typed 'gift' transaction in the audit log. This is the canonical path for any admin-initiated positive balance adjustment, distinct from future paid top-ups (which will use payment_* transaction types).
Validation:
- amountUSDMicrocents must be > 0.
- reason must be non-empty.
- adminUsername must be non-empty.
The credit_transactions row's metadata is empty for gifts -- gifts carry the operator's reason in the reason column and the responsible admin in admin_username, with no other observable per-gift state.
On a successful gift, emits a logging.LogSecurityEvent so the operation shows up in the security event log alongside admin actions.
func ResetCachedRateForTest ¶
func ResetCachedRateForTest()
ResetCachedRateForTest is a test-only helper. Production callers use SetCachedRate via SetCustomerPrice / ResolveRate.
func SeedCustomerPriceIfMissing ¶
SeedCustomerPriceIfMissing inserts the env-derived customer price into billing_settings only if no row exists yet. Idempotent. Called once at startup. Returns the seeded (or already-present) price string.
func SetCachedRate ¶
func SetCachedRate(r *Rate)
SetCachedRate atomically swaps the cached rate. Called by ResolveRate after a successful read and by the admin set-price endpoint immediately on update.
func TickAllActiveUsers ¶
func TickAllActiveUsers(db *sql.DB, rate *Rate, now time.Time, cfg config.BillingConfig) (count int, errCount int, err error)
TickAllActiveUsers ticks every billable user in one pass. Returns the count of users ticked successfully and the count that errored. Per-user errors are logged but do not abort the iteration so a single bad row doesn't block all other users from being billed.
Filtering rules:
- is_approved = true (only approved users are billed).
- !is_admin OR cfg.IncludeAdmins (admins skipped by default to keep beta-period usage data free of operator self-usage).
func TickUser ¶
func TickUser(db *sql.DB, username string, rate *Rate, now time.Time, freeBaselineBytes int64) error
TickUser charges one user for one tick (one wall-clock hour). It reads the user's current total_storage_bytes, computes the billable bytes against freeBaselineBytes, calculates the per-tick charge in microcents, and upserts the storage_usage_accumulator row. Idempotent within a single transaction.
When tick_charge_microcents == 0 (user at or below the free baseline), no row is written -- this keeps the DB completely free of below-baseline noise.
Math:
billable_bytes = max(0, total_storage_bytes - free_baseline_bytes) tick_charge_microcents = (billable_bytes * rate_microcents_per_gib_per_hour) >> 30
The right-shift is integer division by 2^30 (binary GiB), and truncates the fractional remainder. At the spec's representative rate of 1,356 microcents/GiB/hour, the truncated fraction is < 1 microcent/hour per user, well below noise floor.
Types ¶
type Rate ¶
type Rate struct {
// MicrocentsPerGiBPerHour is the canonical internal unit. All per-tick
// math uses this directly.
MicrocentsPerGiBPerHour int64
// CustomerPriceUSDPerTBPerMonth is the dollars-and-cents string that the
// operator set (e.g. "10.00", "19.99"). Used for human-readable display.
CustomerPriceUSDPerTBPerMonth string
// ResolvedAt is when this rate was last read from billing_settings.
ResolvedAt time.Time
}
Rate is the fully-resolved billing rate, ready for use by TickUser.
func CachedRate ¶
func CachedRate() *Rate
CachedRate returns the live cached rate, or nil if not yet resolved. Cheap; safe to call from hot paths.
func ResolveRate ¶
ResolveRate reads the customer price from billing_settings, parses it, computes the internal rate, caches the result, and returns it.
Resolution order:
- billing_settings row (authoritative).
- cfg.Billing.CustomerPriceUSDPerTBPerMonth (env-var-derived seed).
- HardcodedSafetyPriceUSDPerTBPerMonth ("10.00").
Each fallback step logs an ERROR so silent degradation is visible.
func SetCustomerPrice ¶
SetCustomerPrice persists a new customer price to billing_settings, atomically updates the cached rate, and returns the resolved Rate. The atomic swap ensures the very next tick observes the new rate.
Validates that priceStr parses as positive dollars-and-cents.
func (*Rate) FormatHumanReadable ¶
FormatHumanReadable returns "$10.00/TiB/month".
type Scheduler ¶
type Scheduler struct {
// contains filtered or unexported fields
}
Scheduler is the wall-clock-aligned billing loop. One instance per process, started from main.go after DB and storage are up.
Two cadences:
- Tick (every cfg.TickInterval, default 1h) -- charges every billable user via TickAllActiveUsers and writes to storage_usage_accumulator.
- Sweep (once per day at cfg.SweepAtUTC, default 00:15 UTC) -- drains accumulator into user_credits via SweepAllUsers.
Wall-clock alignment: ticks fire at top-of-tick-interval (e.g. on the hour when TickInterval=1h). Sweeps fire at the configured HH:MM each UTC day. This means restart semantics are at-least-once: if a redeploy bridges a tick boundary, the next aligned tick fires immediately. The accumulator's `+= excluded.unbilled_microcents` correctly handles the brief overlap.
nowFn is injectable for deterministic tests. Production callers use the default time.Now.
func NewScheduler ¶
func NewScheduler(db *sql.DB, cfg config.BillingConfig) *Scheduler
NewScheduler returns a configured Scheduler. nowFn defaults to time.Now; sleepFn defaults to time.Sleep. Tests inject both.
func (*Scheduler) Run ¶
Run blocks until ctx is cancelled. Returns ctx.Err() on shutdown, never nil.
Behavior on startup:
- Seed billing_settings if missing (idempotent).
- Resolve the live rate.
- Compute the next aligned tick boundary, sleep until then.
- On each tick, run TickAllActiveUsers; if today's sweep boundary has also been crossed, run SweepAllUsers.
- Repeat until ctx is cancelled.
func (*Scheduler) SetSleepFn ¶
SetSleepFn overrides the sleep function. Test-only.
type SettlementMetadata ¶
type SettlementMetadata struct {
DrainedMicrocents int64 `json:"drained_microcents"`
RateMicrocentsPerGiBPerHour int64 `json:"rate_microcents_per_gib_per_hour"`
PeriodStart time.Time `json:"period_start"`
PeriodEnd time.Time `json:"period_end"`
TicksCount int `json:"ticks_count"`
}
SettlementMetadata is the JSON structure stored in credit_transactions.metadata for a 'usage' row. It must contain ONLY these fields. Adding `avg_billable_bytes` or any per-day storage time-series field would be a privacy regression (§3.5 of the design doc).
type SweepSummary ¶
type SweepSummary struct {
UsersSettled int
TotalDrainedMicrocents int64
UsersWithNegativeBalance int
}
SweepSummary is the aggregate result of one daily settlement run.
func SweepAllUsers ¶
SweepAllUsers performs the daily settlement: drains every nonzero storage_usage_accumulator row into user_credits and writes one 'usage' transaction per user. Each user is processed in its own DB transaction, so a crash mid-iteration leaves already-settled users correct and the next sweep picks up the rest.
The metadata field on each transaction row contains exactly the five fields in SettlementMetadata. It deliberately omits any per-day storage time-series to preserve the privacy invariant in §3.5 of the design doc.
Returns the aggregate summary. UsersWithNegativeBalance is the point-in-time count of users whose balance ended up below zero after this sweep run.