domain

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Nov 12, 2026 License: AGPL-3.0 Imports: 12 Imported by: 0

Documentation

Overview

Package domain (piece ⑧) holds the entities and business rules of Exhale. It is pure Go: no database, no HTTP, no channels.

Index

Constants

View Source
const (
	DefaultConfirmTimes     = 3
	DefaultConfirmEveryDays = 1
	MaxConfirmTimes         = 10
	MaxConfirmEveryDays     = 30
)

The unanswered "was it charged?" question is asked again every day, up to three times in all, unless the household chooses otherwise.

View Source
const (
	PrefixHousehold     = "hh"
	PrefixMember        = "mem"
	PrefixSubscription  = "sub"
	PrefixCharge        = "chg"
	PrefixPaymentMethod = "pm"
	PrefixCategory      = "cat"
	PrefixEvent         = "evt"
	PrefixFlow          = "flw"
	PrefixOutbox        = "out"
)

ID prefixes make identifiers self-describing for humans and AI agents ("sub_…" is a subscription, "chg_…" a charge).

View Source
const (
	MaxTags      = 20
	MaxTagLength = 40
)

Tags are free labels, many per subscription ("work", "family", "to review"). They group across categories; totals stay by category, so a subscription is never counted twice.

Variables

View Source
var DefaultCategories = []string{"Streaming", "Music", "Software", "Cloud & Dev", "AI", "News", "Gaming", "Health & Fitness", "Utilities", "Other"}

DefaultCategories are seeded for new households.

View Source
var ErrConflict = errors.New("conflict")

ErrConflict is returned on concurrent modification.

View Source
var ErrNotFound = errors.New("not found")

ErrNotFound is returned when an entity does not exist (or is not visible).

View Source
var ErrValidation = errors.New("validation failed")

ErrValidation wraps every business-rule violation.

Functions

func BadBillingRule

func BadBillingRule(r engine.Rule, err error) error

BadBillingRule says why the engine refused r, a rule that may use a weekday of the month or a weekend shift (err from r.Validate).

func BadDate

func BadDate(what Word, s string) error

BadDate says that s, given for the field what, is not a date.

func BadPrice

func BadPrice(amount, currency string) error

BadPrice says why amount in currency could not be read.

func BadRule

func BadRule(interval int, unit string, err error) error

BadRule says why a billing cycle of interval × unit was refused by the engine (err).

func CanTransition

func CanTransition(from, to ChargeStatus) bool

CanTransition reports whether a charge may go from one status to another. Every transition back to pending exists so that undo is always possible.

func Capitalize

func Capitalize(s string) string

Capitalize upper-cases the first letter, for a sentence shown alone.

func Conflict

func Conflict(format string, args ...any) error

Conflict is "conflict: <text>".

func Fail

func Fail(kind error, format string, args ...any) error

Fail is an error of another kind (app.ErrForbidden): "<kind>: <text>".

func HasPrefix

func HasPrefix(id, prefix string) bool

HasPrefix reports whether id carries the given prefix.

func HasTag

func HasTag(tags []string, tag string) bool

HasTag reports whether tags contains tag.

func Invalid

func Invalid(format string, args ...any) error

Invalid is a validation error: "validation failed: <text>".

func Missing

func Missing(format string, args ...any) error

Missing is a not-found error whose text says it all, for example "subscription sub_x: not found".

func NewID

func NewID(prefix string) string

NewID returns a random, URL-safe identifier such as "sub_7k3m9q2x4w8z1c5v".

func NormalizeTags

func NormalizeTags(tags []string) []string

NormalizeTags trims each tag, collapses inner spaces, drops empty ones and duplicates (case-insensitive, the first spelling wins) and keeps the order.

func NotFound

func NotFound(format string, args ...any) error

NotFound is "not found: <text>".

func SameTag

func SameTag(a, b string) bool

SameTag compares two tags the way Exhale does: ignoring case.

func ValidateConfirmRepeat

func ValidateConfirmRepeat(times, everyDays int) error

ValidateConfirmRepeat checks how often an unanswered question repeats.

func ValidateTag

func ValidateTag(t string) error

ValidateTag checks one tag. Commas are not allowed: they separate tags in CSV files and in chat.

func WebKey

func WebKey(format string) string

WebKey turns a Go format into the web's: "unknown currency %q" becomes "unknown currency {0}".

Types

type AltPrice

type AltPrice struct {
	Price money.Money `json:"price"`
	// Unit is the period the price covers: month or year (every 1 unit).
	Unit engine.Unit `json:"unit" enum:"month,year"`
}

AltPrice is another price the provider offers for the same service, e.g. "also 99 € per year" for a monthly plan. Exhale only uses it to show what switching would save; it never guesses one.

type Category

type Category struct {
	ID          string `json:"id"`
	HouseholdID string `json:"householdId"`
	Name        string `json:"name"`
	Color       string `json:"color,omitempty"`
}

Category groups subscriptions for statistics.

type Charge

type Charge struct {
	ID             string       `json:"id"`
	SubscriptionID string       `json:"subscriptionId"`
	OriginalDate   engine.Date  `json:"originalDate"`
	DueDate        engine.Date  `json:"dueDate"`
	Amount         money.Money  `json:"amount"`
	ChargedAmount  money.Money  `json:"chargedAmount,omitzero"` // what the bank actually took, any currency
	Status         ChargeStatus `json:"status"`
	ResolvedAt     *time.Time   `json:"resolvedAt,omitempty"`
	Note           string       `json:"note,omitempty"`
	// Decision records that the member chose to keep this renewal ("keep"),
	// so Exhale stops asking before the cancellation deadline.
	Decision  string    `json:"decision,omitempty"`
	UpdatedAt time.Time `json:"updatedAt"`
	Version   int       `json:"version"`
}

Charge is one occurrence of a subscription's schedule (ADR 0003: charges are events with state). It is identified by (SubscriptionID, OriginalDate); DueDate may move ("this charge only").

func (Charge) Delayed

func (c Charge) Delayed() bool

Delayed reports whether the charge was moved away from its scheduled date.

func (Charge) Open

func (c Charge) Open() bool

Open reports whether the charge still needs to happen or be confirmed.

func (*Charge) Transition

func (c *Charge) Transition(to ChargeStatus, at time.Time) error

Transition moves the charge to a new status.

type ChargeStatus

type ChargeStatus string

ChargeStatus is the state of one concrete charge. "Delayed" is not a status: it is a pending charge whose DueDate differs from OriginalDate.

const (
	ChargePending ChargeStatus = "pending"
	ChargePaid    ChargeStatus = "paid"
	ChargeFailed  ChargeStatus = "failed"
	ChargeSkipped ChargeStatus = "skipped"
)

type Details

type Details struct {
	// StartedOn is when the member first subscribed (may be long before the
	// first charge Exhale knows about).
	StartedOn engine.Date `json:"startedOn,omitzero"`
	// TrialEndsOn is the day a free trial turns into the first paid charge.
	TrialEndsOn engine.Date `json:"trialEndsOn,omitzero"`
	// NoticeDays is how many days before a renewal the provider must be told
	// to cancel (0: cancel any time before the charge).
	NoticeDays int     `json:"noticeDays,omitempty"`
	Renewal    Renewal `json:"renewal,omitempty"`
	// CancelURL is where the subscription is cancelled or managed.
	CancelURL string `json:"cancelUrl,omitempty"`
	// Account is the login the subscription lives under ("ana@work.com").
	Account string `json:"account,omitempty"`
	// Shares split the cost between members; empty means the payer carries it.
	Shares []Share `json:"shares,omitempty"`
	// NextPrice is a scheduled price change.
	NextPrice *PriceChange `json:"nextPrice,omitempty"`
	Usage     *Usage       `json:"usage,omitempty"`
	// Tags are free labels (tags.go).
	Tags []string `json:"tags,omitempty"`
	// AltPrice is another price the provider offers, e.g. yearly (tags.go).
	AltPrice *AltPrice `json:"altPrice,omitempty"`
}

Details are the contract facts of a subscription beyond price and schedule. They decide which dates matter: the end of a free trial, the last day to cancel, the day something expires.

func (Details) Validate

func (d Details) Validate() error

Validate checks the contract facts.

type EditScope

type EditScope string

EditScope says whether an edit applies to one charge or to the series.

const (
	ScopeThisCharge   EditScope = "this"
	ScopeThisAndAfter EditScope = "following"
)

func DefaultScope

func DefaultScope(f Field) EditScope

DefaultScope returns the pre-selected scope for an edit (the user is always asked; this only saves a tap in the common case): a moved date is usually a one-off delay, while price and payment-method changes are permanent.

type Error

type Error struct {
	Kind error
	Text Text
	// contains filtered or unexported fields
}

Error is a failure people can be shown in their language. Kind is the sentinel errors.Is matches: ErrValidation, ErrNotFound, ErrConflict, or another one such as app.ErrForbidden.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap returns the kind, for errors.Is.

type Field

type Field string

Field identifies what an edit changes, for scope defaults.

const (
	FieldDate          Field = "date"
	FieldAmount        Field = "amount"
	FieldPaymentMethod Field = "paymentMethod"
)

type Household

type Household struct {
	ID       string `json:"id"`
	Name     string `json:"name"`
	Currency string `json:"currency"` // main currency, ISO 4217
	Timezone string `json:"timezone"` // IANA name, e.g. "Europe/Madrid"
	Locale   string `json:"locale"`   // e.g. "es", "en"
	// Default time of day for reminders, in Timezone (09:00 by default).
	ReminderHour   int `json:"reminderHour"`
	ReminderMinute int `json:"reminderMinute"`
	// Payday is the day of the month the household gets paid (0: not set;
	// 31 means the last day of shorter months).
	Payday int `json:"payday,omitempty"`
	// An unanswered "was it charged?" is asked ConfirmTimes times in all,
	// every ConfirmEveryDays days. New subscriptions copy it.
	ConfirmTimes     int `json:"confirmTimes"`
	ConfirmEveryDays int `json:"confirmEveryDays"`
}

Household is one Exhale instance's family or group (ADR 0006).

func (Household) Location

func (h Household) Location() *time.Location

Location returns the household time zone (UTC if unknown).

func (Household) NextPayday

func (h Household) NextPayday(d engine.Date) (engine.Date, bool)

NextPayday returns the next payday strictly after day d, if one is set.

type Member

type Member struct {
	ID          string `json:"id"`
	HouseholdID string `json:"householdId"`
	Name        string `json:"name"`
	Email       string `json:"email,omitempty"`
	Role        Role   `json:"role"`
	// Locale is the language this person chose; empty: the household's.
	Locale string `json:"locale,omitempty"`
}

Member is a person in the household.

type PaymentMethod

type PaymentMethod struct {
	ID          string            `json:"id"`
	HouseholdID string            `json:"householdId"`
	Name        string            `json:"name"` // "Revolut", "Visa"
	Kind        PaymentMethodKind `json:"kind"`
	Last4       string            `json:"last4,omitempty"`
	ExpiresOn   engine.Date       `json:"expiresOn,omitzero"` // last valid day
	OwnerID     string            `json:"ownerId,omitempty"`
}

PaymentMethod is a card, account or wallet that pays subscriptions.

func (PaymentMethod) Label

func (p PaymentMethod) Label() string

Label returns "Revolut ••0937".

type PaymentMethodKind

type PaymentMethodKind string

PaymentMethodKind classifies payment methods.

const (
	KindCard   PaymentMethodKind = "card"
	KindBank   PaymentMethodKind = "bank"
	KindPayPal PaymentMethodKind = "paypal"
	KindOther  PaymentMethodKind = "other"
)

type PriceChange

type PriceChange struct {
	From  engine.Date `json:"from"`
	Price money.Money `json:"price"`
}

PriceChange is a price that starts applying on a known date.

type ReminderPolicy

type ReminderPolicy struct {
	DaysBefore []int `json:"daysBefore"` // e.g. [3, 0]: three days before and on the day
	Hour       int   `json:"hour"`       // local time of day in the household zone
	Minute     int   `json:"minute"`
	// ConfirmAfterDays asks "was it charged?" this many days after the due
	// date; < 0 disables the question.
	ConfirmAfterDays int `json:"confirmAfterDays"`
	// ConfirmTimes is how many times "was it charged?" is asked while nobody
	// answers (1: once), ConfirmEveryDays the days between two asks.
	ConfirmTimes     int `json:"confirmTimes"`
	ConfirmEveryDays int `json:"confirmEveryDays"`
}

ReminderPolicy says when to remind about each charge.

func DefaultReminderPolicy

func DefaultReminderPolicy() ReminderPolicy

DefaultReminderPolicy: three days before and on the day at 09:00, then ask for confirmation one day after.

func (ReminderPolicy) ConfirmAsks

func (p ReminderPolicy) ConfirmAsks(due engine.Date) []engine.Date

ConfirmAsks returns the dates "was it charged?" is asked for a charge due on due: the first ask and its repeats.

func (ReminderPolicy) Validate

func (p ReminderPolicy) Validate() error

Validate checks the policy.

type Renewal

type Renewal string

Renewal says what happens when a period ends.

const (
	// RenewalAuto: the provider charges again by itself (the default).
	RenewalAuto Renewal = "auto"
	// RenewalManual: it expires unless someone renews it (domains bought for
	// one year, licences, prepaid plans, a passport).
	RenewalManual Renewal = "manual"
)

type Role

type Role string

Role of a household member.

const (
	RoleOwner  Role = "owner"
	RoleMember Role = "member"
)

type Saved

type Saved struct {
	F string     `json:"f"`
	A []savedArg `json:"a,omitempty"`
}

Saved is a Text in a form that survives a round trip through JSON, for texts kept in the database (a proposal waiting for confirmation).

func (Saved) Text

func (s Saved) Text() Text

Text returns the saved Text.

type Share

type Share struct {
	MemberID string `json:"memberId"`
	// Weight is relative: two members with weight 1 pay half each; weights 2
	// and 1 split two thirds and one third.
	Weight int `json:"weight"`
}

Share is the part of a subscription's cost one member carries.

type Subscription

type Subscription struct {
	ID              string             `json:"id"`
	HouseholdID     string             `json:"householdId"`
	Name            string             `json:"name"`
	Price           money.Money        `json:"price"`
	Schedule        engine.Schedule    `json:"schedule"`
	CategoryID      string             `json:"categoryId,omitempty"`
	PaymentMethodID string             `json:"paymentMethodId,omitempty"`
	PayerID         string             `json:"payerId,omitempty"`
	Visibility      Visibility         `json:"visibility"`
	Status          SubscriptionStatus `json:"status"`
	URL             string             `json:"url,omitempty"`
	Notes           string             `json:"notes,omitempty"`
	AutoConfirm     bool               `json:"autoConfirm"` // mark charges paid automatically on the due date
	Reminders       ReminderPolicy     `json:"reminders"`
	Details
	CreatedBy string    `json:"createdBy,omitempty"`
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`
	Version   int       `json:"version"` // optimistic locking
}

Subscription is a recurring payment.

func (Subscription) CancelDeadline

func (s Subscription) CancelDeadline(d engine.Date) engine.Date

CancelDeadline returns the last day to cancel before the charge due on day d.

func (Subscription) InTrial

func (s Subscription) InTrial(d engine.Date) bool

InTrial reports whether the free trial is still running on day d.

func (Subscription) ManualRenewal

func (s Subscription) ManualRenewal() bool

ManualRenewal reports whether the subscription expires unless renewed.

func (Subscription) PriceOn

func (s Subscription) PriceOn(d engine.Date) money.Money

PriceOn returns the price of a charge scheduled on day d.

func (Subscription) Validate

func (s Subscription) Validate() error

Validate checks the subscription's own fields.

func (Subscription) VisibleTo

func (s Subscription) VisibleTo(memberID string) bool

VisibleTo reports whether member m may see the subscription.

type SubscriptionStatus

type SubscriptionStatus string

SubscriptionStatus is the lifecycle state of a subscription.

const (
	StatusActive    SubscriptionStatus = "active"
	StatusPaused    SubscriptionStatus = "paused"
	StatusCancelled SubscriptionStatus = "cancelled"
)

type Text

type Text struct {
	Format string
	Args   []any
}

Text is a sentence: Format is the English text with fmt verbs and the key of its translations; Args fill the verbs. An argument that is a Word, a Text or an error carrying a Text is translated too.

func T

func T(format string, args ...any) Text

T returns a Text.

func TextOf

func TextOf(err error) (Text, bool)

TextOf returns what an error says to people, if it was written with one of the constructors above (possibly wrapped).

func (Text) IsZero

func (t Text) IsZero() bool

IsZero reports whether t is empty.

func (Text) MarshalJSON

func (t Text) MarshalJSON() ([]byte, error)

MarshalJSON sends the web's form.

func (Text) Render

func (t Text) Render(translate func(string) string) string

Render writes the sentence, looking every format and word up with translate (nil: English).

func (Text) Save

func (t Text) Save() Saved

Save returns the savable form of t. Arguments that are not strings, numbers, words or texts are saved as their English text.

func (Text) String

func (t Text) String() string

String is the sentence in English.

func (Text) Wire

func (t Text) Wire() Wire

Wire returns the web's form of t.

type Usage

type Usage struct {
	Using bool      `json:"using"`
	At    time.Time `json:"at"`
}

Usage is the member's last answer to "still using this?".

type Visibility

type Visibility string

Visibility of a subscription inside the household.

const (
	VisibilityShared  Visibility = "shared"
	VisibilityPrivate Visibility = "private"
)

type Wire

type Wire struct {
	Key  string `json:"key"`
	Args []any  `json:"args"`
}

Wire is a Text as the web receives it: Key is the format with {0}, {1}… in place of the verbs (the key of the web's catalogs), and each argument is either a string, already formatted, or a nested Wire to translate.

type Word

type Word string

Word is an argument from a closed set (a status, the name of a field) that is translated as well.

Jump to

Keyboard shortcuts

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