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
- Variables
- type Amount
- type Config
- type GroupAccess
- type GroupJoin
- type LibraryItem
- type LibraryPage
- type LibraryQuery
- type ListQuery
- type Offer
- type OfferPage
- type Options
- type Put
- type Sale
- type Service
- func (s *Service) Authorize(ctx context.Context, a auth.Authenticator, actor access.Actor, ...) error
- func (s *Service) Current(ctx context.Context, a auth.Authenticator, actor access.Actor, ...) ([]*Offer, error)
- func (s *Service) GroupAccess(ctx context.Context, viewer access.Actor, groups []string) ([]GroupAccess, error)
- func (s *Service) Handler(a auth.Authenticator) (http.Handler, error)
- func (s *Service) Join(ctx context.Context, actor access.Actor, groups []string) ([]GroupJoin, error)
- func (s *Service) Joined(ctx context.Context, viewer access.Actor) ([]string, error)
- func (s *Service) Leave(ctx context.Context, actor access.Actor, groups []string) ([]GroupJoin, error)
- func (s *Service) Library(ctx context.Context, actor access.Actor, q LibraryQuery) (LibraryPage, error)
- func (s *Service) List(ctx context.Context, q ListQuery) (OfferPage, error)
- func (s *Service) Live(ctx context.Context, keys []access.Key) (map[string]Offer, error)
- func (s *Service) Put(ctx context.Context, puts []Put) ([]Offer, error)
- func (s *Service) PutTx(ctx context.Context, tx pgx.Tx, puts []Put) error
- func (s *Service) Reconcile(ctx context.Context) error
- func (s *Service) Resync(ctx context.Context, tx pgx.Tx, targets []Target) error
- func (s *Service) RiverJobs() riverhelpers.Contribution
- func (s *Service) Sales(ctx context.Context, keys []string) (map[string]Sale, error)
- func (s *Service) Sync(ctx context.Context, targets []Target) error
- func (s *Service) Withdraw(ctx context.Context, targets []Target) ([]Offer, error)
- type State
- type Target
- type TargetKind
- type Terms
Constants ¶
const PriceKey = "offer"
PriceKey names an offer's price under its product.
const ReconcileInterval = time.Minute
ReconcileInterval is how often the engine queues a sync of every offer OpenRails does not match yet.
Variables ¶
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 ¶
ParseAmount parses a non-negative decimal with at most six decimals.
func (Amount) MarshalText ¶
func (*Amount) UnmarshalJSON ¶
UnmarshalJSON takes a decimal string or number.
func (*Amount) UnmarshalText ¶
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 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 (*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 ¶
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 ErrNotJoinable.
func (*Service) Joined ¶
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) Live ¶
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 ¶
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) Reconcile ¶
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 ¶
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 ¶
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 ¶
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.
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.