Files
member-console/internal/instance/settings.go
T
cgalo5758 56e65b669c Read invoices back from Stripe and archive the change
Stripe delivers webhook events in the endpoint's API version. Under a
version later than the 2025-02-24.acacia stripe-go v81 pins, the
payload-shaped invoice handlers projected every invoice with no
subscription link and no lines, and every payment with no payment
intent or method.

- Invoice events read only the invoice id and run ReconcileInvoice,
  which reads the invoice, its lines and its payment intent's charges
  back at the pinned version and converges the core rows under a
  per-invoice lock, in any event order. A payment row is one attempt,
  keyed by its Stripe charge (Stripe store migration 00005).
- Two clocks: created_at is when the console wrote a row; invoices gain
  issued_at and payments paid_at (core migration 00022, backfilled),
  and every invoice and payment list, the activity feed and the
  composite's latest invoice read those.
- An hourly and at-boot invoice sweep reads back invoices never read,
  open invoices once a day, and invoices of dead-lettered events; the
  provider page states when it last finished. The first run is the
  repair of invoices projected so far.
- The API version skew is logged once per version, and
  payment_method.attached links payments recorded before it.
- A restricted key now needs Invoices read and Charges read.
- scripts/db-reader-role.sql maintains console_reader, a login role
  that reads the console's tables without personal data, documented in
  docs/database-management.md and checked against a golden column list
  by TestConsoleReaderColumns. The demo snapshot keeps owners and
  privileges so the role's checks hold on it.
- The demo seed and test fixtures carry issue and paid times.
  status/design-feedback.md gains four upstream notes, one written when
  #60 was closed.

OpenSpec change stripe-invoice-reconcile is archived and its deltas
synced into openspec/specs/. Follow-ups are #150, #151 and #152.
2026-09-28 18:42:29 -05:00

175 lines
6.9 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package instance
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
)
// This package is the seed of the deployment-wide "instance settings" store
// (design D28): runtime state the console writes at request time (an
// operator dismissing something, flipping a switch), distinct from
// core.integration_config_overrides, which is boot-applied configuration
// that only takes effect on restart. Where a config override changes what
// the process reads at startup, an instance setting changes what a request
// handler reads right now.
//
// Nothing writes an ad hoc key: every key this package will ever read or
// write is declared in the registry below, so a typo in a key string fails
// GetBool/SetBool loudly instead of silently reading/writing a key nobody
// declared.
// Key names one instance setting. A typed alias rather than a bare string so
// a caller cannot pass an undeclared key without an explicit conversion.
type Key string
// SetupBannerDismissed is the first instance setting: whether the operator
// overview's setup banner has been dismissed, deployment-wide (design D12).
const SetupBannerDismissed Key = "setup_banner_dismissed"
// StripeEnvironmentCheck records which API key the last Stripe environment
// check ran under and what it found (stripe-environment-stamp D3). The
// value is a JSON object; its fields belong to the Stripe package, which
// marshals and unmarshals them, so this package stores the bytes and does
// not name the shape.
const StripeEnvironmentCheck Key = "stripe.environment_check"
// StripePublicDetails records the Stripe account's public details as the
// last read found them: the business name, the statement descriptor and
// the icon members meet in Stripe's pages (merchant-disclosure D3). A JSON
// object whose fields belong to the Stripe package, like the one above.
const StripePublicDetails Key = "stripe.public_details"
// StripeInvoiceSweep records when the invoice sweep last finished, how
// many invoices it read back and how many it could not reconcile
// (stripe-invoice-reconcile D12). A JSON object whose fields belong to the
// Stripe package, like the two above.
const StripeInvoiceSweep Key = "stripe.invoice_sweep"
// settingKind names the JSON shape a key's value holds, so the accessors
// can refuse a key that was never declared as their shape rather than
// silently coercing whatever JSON happens to be stored under it.
type settingKind string
const (
kindBool settingKind = "bool"
// kindJSON is a JSON object whose fields the caller owns. This package
// carries the bytes and the registry entry; the shape lives with the
// code that has a reason to know it.
kindJSON settingKind = "json"
)
// registry is the allowed-keys list: every key this package will read or
// write, and the value shape it holds. A key absent from this map is not a
// real instance setting, whether or not a row happens to exist for it.
var registry = map[Key]settingKind{
SetupBannerDismissed: kindBool,
StripeEnvironmentCheck: kindJSON,
StripePublicDetails: kindJSON,
StripeInvoiceSweep: kindJSON,
}
// Store is the typed accessor over core.instance_settings. Built from a
// Querier so a caller can inject a *Queries backed by a *sql.DB, a *sql.Tx,
// or a test fake.
type Store struct {
q Querier
}
// NewStore builds a Store backed by db (typically the application's
// *sql.DB; DBTX also accepts a *sql.Tx for transaction-scoped tests).
func NewStore(db DBTX) *Store {
return &Store{q: New(db)}
}
// GetBool reads a boolean instance setting. A key with no stored row reads
// as false with no error — an instance setting that has never been written
// is indistinguishable from one explicitly set to its zero value, and every
// key declared here defaults to false until an operator acts. An undeclared
// key, or one declared with a different kind, is an error: this package
// never reads a key it did not itself register.
func (s *Store) GetBool(ctx context.Context, key Key) (bool, error) {
if kind, ok := registry[key]; !ok || kind != kindBool {
return false, fmt.Errorf("instance: %q is not a declared boolean setting", key)
}
row, err := s.q.GetInstanceSetting(ctx, string(key))
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
var value bool
if err := json.Unmarshal(row.Value, &value); err != nil {
return false, fmt.Errorf("instance: decode %q: %w", key, err)
}
return value, nil
}
// SetBool writes a boolean instance setting, recording who set it. updatedBy
// is stored as-is (typically the operator's signed-in email/subject); an
// empty string stores NULL, matching the config-override write path's
// convention for an unknown actor.
func (s *Store) SetBool(ctx context.Context, key Key, value bool, updatedBy string) error {
if kind, ok := registry[key]; !ok || kind != kindBool {
return fmt.Errorf("instance: %q is not a declared boolean setting", key)
}
raw, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("instance: encode %q: %w", key, err)
}
by := sql.NullString{}
if updatedBy != "" {
by = sql.NullString{String: updatedBy, Valid: true}
}
return s.q.UpsertInstanceSetting(ctx, UpsertInstanceSettingParams{
Key: string(key), Value: raw, UpdatedBy: by,
})
}
// GetJSON reads a JSON-object instance setting as the stored bytes. The
// second return is false when no row exists, which GetBool can fold into
// its zero value but a JSON object cannot: "never written" and "written as
// {}" are different answers, and the caller needs to tell them apart. The
// caller unmarshals the bytes into its own shape, so this package never
// names one.
func (s *Store) GetJSON(ctx context.Context, key Key) (json.RawMessage, bool, error) {
if kind, ok := registry[key]; !ok || kind != kindJSON {
return nil, false, fmt.Errorf("instance: %q is not a declared JSON setting", key)
}
row, err := s.q.GetInstanceSetting(ctx, string(key))
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return json.RawMessage(row.Value), true, nil
}
// SetJSON writes a JSON-object instance setting, recording who set it.
// value is marshalled here, so a caller passes its own struct. updatedBy
// follows SetBool: stored as-is, empty stores NULL. The upsert stamps
// updated_at, which is how a reader dates the record.
func (s *Store) SetJSON(ctx context.Context, key Key, value any, updatedBy string) error {
if kind, ok := registry[key]; !ok || kind != kindJSON {
return fmt.Errorf("instance: %q is not a declared JSON setting", key)
}
raw, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("instance: encode %q: %w", key, err)
}
by := sql.NullString{}
if updatedBy != "" {
by = sql.NullString{String: updatedBy, Valid: true}
}
return s.q.UpsertInstanceSetting(ctx, UpsertInstanceSettingParams{
Key: string(key), Value: raw, UpdatedBy: by,
})
}