offers

package
v0.98.0 Latest Latest
Warning

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

Go to latest
Published: Oct 16, 2026 License: MIT Imports: 26 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")
	// ErrNotJoinable is a join of a group without a free offer.
	ErrNotJoinable = errors.New("offers: not free to join")
)

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. Internal or inactive entities return ErrNotFound. There is 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). Internal entities cannot be joined. A viewer already holding the key costs no grant. A group without a free offer is ErrNotJoinable.

func (*Service) Joined

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

Joined is the public 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, within the current domain's hard visibility boundary.

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