offers

package
v0.83.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 13, 2026 License: MIT Imports: 25 Imported by: 0

Documentation

Overview

Package offers sells content, versions and groups. Staff and creators put an offer (terms) on a target; an engine River job syncs it to OpenRails as a product whose key and only entitlement is the target's access key, with one price under PriceKey. Prices are immutable, so a reprice is a new price; a withdrawal archives the product. It also serves free group joins and the viewer's library.

Index

Constants

View Source
const PriceKey = "offer"

PriceKey names an offer's price under its product.

View Source
const ReconcileInterval = time.Minute

ReconcileInterval is how often the engine queues a sync of every offer OpenRails does not match yet.

Variables

View Source
var (
	ErrInvalid   = errors.New("offers: invalid")
	ErrNotFound  = errors.New("offers: not found")
	ErrForbidden = errors.New("offers: forbidden")
	ErrConflict  = errors.New("offers: conflict")
)

Functions

This section is empty.

Types

type Amount

type Amount int64

Amount is money in micros (millionths) of a currency unit. It reads and writes as a decimal: "4.99" (JSON strings or numbers, YAML scalars).

const MaxAmount Amount = 999_999_990_000

MaxAmount bounds every price: 999999.99.

func ParseAmount

func ParseAmount(s string) (Amount, error)

ParseAmount parses a non-negative decimal with at most six decimals.

func (Amount) MarshalText

func (a Amount) MarshalText() ([]byte, error)

func (Amount) String

func (a Amount) String() string

String is the shortest decimal with at least two decimals: "4.99", "0.50".

func (*Amount) UnmarshalJSON

func (a *Amount) UnmarshalJSON(b []byte) error

UnmarshalJSON takes a decimal string or number.

func (*Amount) UnmarshalText

func (a *Amount) UnmarshalText(b []byte) error

type Config

type Config struct {
	// Currency is the default currency: USD when empty.
	Currency string `json:"currency,omitempty" yaml:"currency"`
	// Minimum is the least amount per target kind; a version without its own
	// takes content's. Free (0) is allowed for groups only.
	Minimum map[TargetKind]Amount `json:"minimum,omitempty" yaml:"minimum"`
	// PSPs are the payment providers selling every price.
	PSPs []string `json:"psps,omitempty" yaml:"psps"`
}

Config is the engine's offer policy.

type GroupAccess

type GroupAccess struct {
	Key access.Key `json:"key"`
	// Offer is the active offer as paywalls show it (Sales); nil while the
	// group sells nothing.
	Offer *Sale `json:"offer,omitempty"`
	// Member: the viewer holds the key (bought, subscribed or joined free).
	Member bool `json:"member"`
	// Editor: the viewer edits the group's content (its owner, a holder of
	// {kind}:content:write in its AuthKit scope), so opens it all and is
	// offered nothing.
	Editor bool `json:"editor,omitempty"`
	// Unknown: the billing read failed, so Member is false and may be wrong.
	Unknown bool `json:"unknown,omitempty"`
}

GroupAccess is a sellable group's sale as a card or page shows it: its key, its active offer and the viewer's standing.

type GroupJoin

type GroupJoin struct {
	GroupID string     `json:"group_id"`
	Key     access.Key `json:"key"`
	Member  bool       `json:"member"`
}

GroupJoin is a viewer's free membership of a group.

type LibraryItem

type LibraryItem struct {
	ContentID string `json:"content_id"`
	Kind      string `json:"kind"`
	Title     string `json:"title"`
	Language  string `json:"language,omitempty"`
	// Deleted content stays readable to its buyers.
	Deleted bool `json:"deleted"`
	// Whole: the content key, every version. Otherwise VersionIDs are the
	// versions bought.
	Whole      bool     `json:"whole"`
	VersionIDs []string `json:"version_ids"`
}

LibraryItem is one content item the viewer bought.

type LibraryPage

type LibraryPage struct {
	Items      []LibraryItem `json:"items"`
	NextCursor string        `json:"next_cursor,omitempty"`
	// Complete is false when the viewer holds more keys than one billing read
	// returns (access.HeldLimit); the library lists those it read.
	Complete bool `json:"complete"`
}

LibraryPage is one page of a library, newest content first.

type LibraryQuery

type LibraryQuery struct {
	// Language picks the titles; the configured languages follow.
	Language string
	Cursor   string // the previous page's next_cursor
	Limit    int    // 20 by default, at most 100
}

LibraryQuery pages a viewer's library.

type ListQuery

type ListQuery struct {
	State    State // empty: any
	Unsynced bool  // only those OpenRails does not match yet
	Cursor   string
	Limit    int
}

ListQuery pages offers for staff.

type Offer

type Offer struct {
	ID     string     `json:"id"`
	Target Target     `json:"target"`
	Key    access.Key `json:"key"`
	Terms  Terms      `json:"terms"`
	State  State      `json:"state"`
	// Synced: OpenRails sells it on these terms (or has taken it off sale).
	Synced    bool      `json:"synced"`
	ProductID string    `json:"product_id,omitempty"`
	PriceID   string    `json:"price_id,omitempty"`
	LastError string    `json:"last_error,omitempty"`
	Revision  int64     `json:"revision"`
	UpdatedAt time.Time `json:"updated_at"`
}

Offer is one offer on a target.

type OfferPage

type OfferPage struct {
	Offers     []Offer `json:"offers"`
	NextCursor string  `json:"next_cursor,omitempty"`
}

OfferPage is one page of offers, newest first.

type Options

type Options struct {
	Pool   *pgxpool.Pool
	Schema string // ContentKit's schema
	Tenant string
	Kinds  kinds.Config
	Config Config
	// Billing syncs offers and grants free joins; Gate answers joins and the
	// library.
	Billing access.Billing
	Gate    *access.Gate
	// Queue is the River queue of the sync jobs; contentkit_offers when empty.
	Queue  string
	Logger *slog.Logger
}

Options configures a Service.

type Put

type Put struct {
	Target Target
	Terms  Terms
}

Put sets a target's offer.

type Sale

type Sale struct {
	Key     access.Key `json:"key"`
	Target  Target     `json:"target"`
	Terms   Terms      `json:"terms"`
	PriceID string     `json:"price_id,omitempty"`
}

Sale is a key's purchasable offer as a paywall shows it: its terms and the price billing checks out.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service is the offers module.

func New

func New(o Options) (*Service, error)

New validates o.

func (*Service) Authorize

func (s *Service) Authorize(ctx context.Context, a auth.Authenticator, actor access.Actor, targets []Target) error

Authorize admits actor to change targets' offers (editors): staff, the content's creators, and editors of the user-owned group sold or owning the content.

func (*Service) Current

func (s *Service) Current(ctx context.Context, a auth.Authenticator, actor access.Actor, targets []Target) ([]*Offer, error)

Current answers, per target in order, its latest offer (active, else the last withdrawn) when actor may change its offers (Authorize); nil when they may not or it never had one.

func (*Service) GroupAccess

func (s *Service) GroupAccess(ctx context.Context, viewer access.Actor, groups []string) ([]GroupAccess, error)

GroupAccess answers, per group in order, its key, active offer and the viewer's standing: one offers read and at most one billing read (access.Gate.Holds). A failed billing read answers Unknown, not an error.

func (*Service) Handler

func (s *Service) Handler(a auth.Authenticator) (http.Handler, error)

Handler serves the offers routes: creator and staff offer edits, free group joins, the viewer's library and the staff offer list. a says who each request is; each request runs in access.WithMemo.

func (*Service) Join

func (s *Service) Join(ctx context.Context, actor access.Actor, groups []string) ([]GroupJoin, error)

Join grants actor each group's key free, through Billing: a sellable group whose active offer is free (amount 0). A viewer already holding the key costs no grant. A group without a free offer is ErrConflict.

func (*Service) Joined

func (s *Service) Joined(ctx context.Context, viewer access.Actor) ([]string, error)

Joined is the groups whose key viewer holds, bought, subscribed or joined free: the request's one entitlements read (access.Gate.Filter), at most access.HeldLimit of them. A failed read answers none.

func (*Service) Leave

func (s *Service) Leave(ctx context.Context, actor access.Actor, groups []string) ([]GroupJoin, error)

Leave takes back each group's free grant from actor; a bought or subscribed membership stays.

func (*Service) Library

func (s *Service) Library(ctx context.Context, actor access.Actor, q LibraryQuery) (LibraryPage, error)

Library lists the content actor holds a content or version key of: the keys under the tenant's content prefix, from the request's one billing read. Deleted content is listed too.

func (*Service) List

func (s *Service) List(ctx context.Context, q ListQuery) (OfferPage, error)

List pages offers, newest first.

func (*Service) Live

func (s *Service) Live(ctx context.Context, keys []access.Key) (map[string]Offer, error)

Live answers the active offers selling keys, by key string: what a paywall (access.Decision.Sell) can sell. Tier keys are the site catalog's.

func (*Service) Put

func (s *Service) Put(ctx context.Context, puts []Put) ([]Offer, error)

Put sets each target's offer, in one transaction: a target without an active offer gets one; new terms move the active offer to a new revision; equal terms change nothing. The target's ever_sold is set and a sync job is queued. It answers the active offers, in order.

func (*Service) PutTx

func (s *Service) PutTx(ctx context.Context, tx pgx.Tx, puts []Put) error

PutTx is Put in tx (the bulk import's).

func (*Service) Reconcile

func (s *Service) Reconcile(ctx context.Context) error

Reconcile queues a sync of every target with an offer OpenRails does not match yet: those whose job was lost, or whose sync keeps failing.

func (*Service) Resync

func (s *Service) Resync(ctx context.Context, tx pgx.Tx, targets []Target) error

Resync queues a sync of the active offers of targets in tx, for a change that moves what OpenRails shows (a content or group renamed); targets without one cost nothing.

func (*Service) RiverJobs

func (s *Service) RiverJobs() riverhelpers.Contribution

RiverJobs contributes the offer sync worker, its queue and the periodic reconcile to the engine's helpers/river composition. It composes once; until bound, Put and Withdraw leave syncing to the reconcile.

func (*Service) Sales

func (s *Service) Sales(ctx context.Context, keys []string) (map[string]Sale, error)

Sales answers, by key string, the purchasable offers selling keys (an access.Decision's Sell): active offers, a paid one once OpenRails sells it on its current terms, with its price; a free group's at once (joining syncs it). Tier keys have none.

func (*Service) Sync

func (s *Service) Sync(ctx context.Context, targets []Target) error

Sync brings OpenRails in line with each target's offers: one product per access key (created when first sold, renamed after the target's title, archived while no offer is active) and, for a paid active offer, the price of its terms; a free offer leaves no price on sale. It is idempotent: prices are keyed by their terms, so a retry changes nothing. Targets are synced one at a time under a lock of their own.

func (*Service) Withdraw

func (s *Service) Withdraw(ctx context.Context, targets []Target) ([]Offer, error)

Withdraw ends each target's active offer, in one transaction, and queues its sync, which archives the product: buyers keep theirs, and a group's subscribers theirs until their paid period ends, without renewing. A target without an active offer is ErrNotFound.

type State

type State string

State is an offer's lifecycle state.

const (
	Active    State = "active"
	Withdrawn State = "withdrawn"
)

type Target

type Target struct {
	Kind    TargetKind `json:"kind"`
	ID      string     `json:"id"`
	Content string     `json:"content_id,omitempty"`
}

Target is one sold thing. Content is a version's content.

type TargetKind

type TargetKind string

TargetKind is what an offer sells.

const (
	ContentTarget TargetKind = "content" // every version of a content item
	VersionTarget TargetKind = "version" // one version
	GroupTarget   TargetKind = "group"   // a sellable group: its members and its members-only content
)

type Terms

type Terms struct {
	// Amount is the price; "0" makes a group free to join.
	Amount Amount `json:"amount"`
	// Currency is an ISO 4217 code; Config.Currency when empty.
	Currency string `json:"currency"`
	// AccessHours is how long a purchase opens it; none is forever.
	AccessHours *int `json:"access_hours,omitempty"`
	// RenewHours makes a group's offer a subscription renewing this often.
	RenewHours *int `json:"renew_hours,omitempty"`
}

Terms are what an offer sells for.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL