app

package
v0.2.0 Latest Latest
Warning

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

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

Documentation

Overview

Package app (piece ⑦) holds every business operation of Exhale. The web, the chat adapters, the scheduler and AI agents all call the same use cases (ADR 0003, 0018).

Index

Constants

View Source
const (
	KindDuplicate  = "duplicate" // "You and Ana both pay for Spotify"
	KindCardExpiry = "card"      // "Visa ••4421 expires in 20 days: 7 subscriptions use it"
)

Outbox kinds of messages that are not about a charge.

View Source
const (
	EventCharge   = "charge"   // a renewal
	EventTrial    = "trial"    // a free trial turns into the first charge
	EventExpiry   = "expiry"   // it expires unless renewed
	EventCancelBy = "cancelBy" // last day to cancel before a renewal
)

Calendar event kinds.

View Source
const (
	// MaxDocumentSize is the largest document one upload can add.
	MaxDocumentSize = 10 << 20
	// MaxLogoUpload is the largest picture accepted as a logo. It is
	// re-encoded to a small PNG before it is stored.
	MaxLogoUpload = 2 << 20
	// MaxHouseholdFiles bounds what one household keeps, deleted files of
	// the last 30 days included.
	MaxHouseholdFiles = 500 << 20
	// LogoSize is the side of the square a logo is fitted into.
	LogoSize = 256

	// FileRetention is how long a deleted file is kept, so undo can bring it
	// back.
	FileRetention = 30 * 24 * time.Hour
)

Limits of files.

View Source
const (
	DocContract = "contract"
	DocInvoice  = "invoice"
	DocOther    = "other"
)

Document kinds. A logo is stored the same way but is not a document.

View Source
const (
	KeepAwakeOff    = "off"
	KeepAwakeAlways = "always"
	KeepAwakeHours  = "hours"
)

Keep-awake modes.

View Source
const (
	AwakeOff         = "off"         // the owner turned it off
	AwakeActive      = "active"      // the machine is kept awake now
	AwakeWaiting     = "waiting"     // outside the hours
	AwakeUnavailable = "unavailable" // not possible here (see Reason)
	AwakeFailed      = "failed"      // the system refused (see Error)
	AwakeUnknown     = "unknown"     // this process does not apply it (exhale mcp)
)

Keep-awake states, as the web shows them.

View Source
const (
	KindReminder = "reminder" // "Netflix renews in 3 days"
	KindConfirm  = "confirm"  // "Was Netflix charged?"
	KindDecision = "decision" // "Last day to cancel the gym is in 7 days: keep or cancel?"
)

Outbox kinds.

View Source
const (
	MatchMarkPaid    = "mark_paid"
	MatchAlreadyPaid = "already_paid"
)

Match actions.

View Source
const (
	ChargeRenewal = "renewal"
	ChargeTrial   = "trial"
	ChargeExpiry  = "expiry"
)

Charge kinds.

View Source
const ExportFormat = "exhale/1"

ExportFormat identifies Exhale's own export.

View Source
const HorizonDays = 120

HorizonDays is how far ahead charges are materialized.

View Source
const MaxAttempts = 8

MaxAttempts is how many times delivery is retried.

View Source
const MaxBulk = 500

MaxBulk bounds one bulk change (and one undo of several changes).

View Source
const MaxImportItems = 500

MaxImportItems bounds one import call.

View Source
const MaxStatementLines = 10_000

MaxStatementLines bounds one statement.

Variables

View Source
var (
	OpGetContext = Op[struct{}, AgentContext]{Meta{
		Name: "get_context", Title: "Read the household briefing", Tag: "Context", Method: "GET", Path: "/context",
		Description: "Start here. Returns a live Markdown briefing: today's date and time zone, members, every subscription with ids, the next 30 days of charges, what needs attention, and the rules for changing anything safely.",
	}, (*Service).BuildContext}
	OpGetHousehold = Op[struct{}, HouseholdInfo]{Meta{
		Name: "get_household", Title: "Get household reference data", Tag: "Household", Method: "GET", Path: "/household",
		Description: "Household settings, members, categories and payment methods with their ids.",
	}, (*Service).GetHousehold}
	OpUpdateHousehold = Op[UpdateHouseholdInput, HouseholdInfo]{Meta{
		Name: "update_household", Title: "Change household preferences", Tag: "Household", Method: "POST", Path: "/household", Write: true, Idempotent: true,
		Description: "Changes the display currency (totals convert into it), time zone, reminder language, name, or default reminder time (HH:MM). Owners only. Also how often an unanswered \"was it charged?\" is asked again (confirmTimes, confirmEveryDays).",
	}, (*Service).UpdateHousehold}
	OpListSubscriptions = Op[ListSubscriptionsInput, SubscriptionList]{Meta{
		Name: "list_subscriptions", Title: "List subscriptions", Tag: "Subscriptions", Method: "GET", Path: "/subscriptions",
		Description: "Lists subscriptions with price, cadence, next charge, monthly cost, category and payment method. Filter by status or name.",
	}, (*Service).ListSubscriptions}
	OpGetSubscription = Op[GetSubscriptionInput, SubscriptionDetail]{Meta{
		Name: "get_subscription", Title: "Get a subscription", Tag: "Subscriptions", Method: "GET", Path: "/subscriptions/{id}",
		Description: "One subscription with its upcoming charges (with charge ids) and its charge history.",
	}, (*Service).GetSubscription}
	OpCreateSubscription = Op[CreateSubscriptionInput, SubscriptionDetail]{Meta{
		Name: "create_subscription", Title: "Create a subscription", Tag: "Subscriptions", Method: "POST", Path: "/subscriptions", Write: true,
		Description: "Adds a recurring subscription and computes its upcoming charges. Also covers a free trial (trialEndsOn + the price it becomes), a notice period (noticeDays), things that expire unless renewed (renewal=manual) and subscriptions that already ended (status=cancelled). Use dryRun to preview the charge dates first.",
	}, (*Service).CreateSubscription}
	OpUpdateSubscription = Op[UpdateSubscriptionInput, SubscriptionDetail]{Meta{
		Name: "update_subscription", Title: "Update a subscription", Tag: "Subscriptions", Method: "POST", Path: "/subscriptions/update", Write: true, Idempotent: true,
		Description: "Changes name, price (from the next open charge on, or from priceFrom for a change announced in advance), billing cycle or day (interval, unit, nextCharge: from the next charge on), end date, category, payment method, payer, cost shares, privacy, URL, notes, auto-confirm, reminders, or the contract terms (start date, trial end, notice period, renewal, cancel link, account). Only the fields you pass change.",
	}, (*Service).UpdateSubscription}
	OpSetSubscriptionStatus = Op[SetSubscriptionStatusInput, SubscriptionDetail]{Meta{
		Name: "set_subscription_status", Title: "Pause, resume or cancel", Tag: "Subscriptions", Method: "POST", Path: "/subscriptions/status", Write: true, Destructive: true, Idempotent: true,
		Description: "Pauses, resumes or cancels a subscription. cancel_at_period_end keeps the service until the day before the next charge; cancel_now stops future charges immediately.",
	}, (*Service).SetSubscriptionStatus}
	OpDeleteSubscription = Op[DeleteSubscriptionInput, DeleteResult]{Meta{
		Name: "delete_subscription", Title: "Delete a subscription", Tag: "Subscriptions", Method: "POST", Path: "/subscriptions/delete", Write: true, Destructive: true,
		Description: "Permanently deletes a subscription and its charges. Prefer set_subscription_status to cancel. Can be reverted with undo.",
	}, (*Service).DeleteSubscription}
	OpListCharges = Op[ListChargesInput, ChargeList]{Meta{
		Name: "list_charges", Title: "List charges by date", Tag: "Charges", Method: "GET", Path: "/charges",
		Description: "Charges due between two dates (default: the next 30 days), with ids, amounts, status and whether they were moved.",
	}, (*Service).ListCharges}
	OpCalendar = Op[CalendarInput, CalendarView]{Meta{
		Name: "calendar", Title: "Calendar of dates", Tag: "Charges", Method: "GET", Path: "/calendar",
		Description: "Every dated event in a period (default: the next year): charges, including the ones the schedule will produce further ahead, free trials ending, things expiring and last days to cancel.",
	}, (*Service).Calendar}
	OpNeedsAttention = Op[struct{}, AttentionList]{Meta{
		Name: "needs_attention", Title: "What needs attention", Tag: "Charges", Method: "GET", Path: "/attention",
		Description: "Charges whose date passed without confirmation, payment methods about to expire, last days to cancel and free trials about to charge.",
	}, (*Service).NeedsAttention}
	OpChangeChargeDate = Op[ChangeChargeDateInput, ChargeResult]{Meta{
		Name: "change_charge_date", Title: "Move a charge", Tag: "Charges", Method: "POST", Path: "/charges/move", Write: true, Idempotent: true,
		Description: "Moves a charge to another date. scope=this (default) for a one-off delay; scope=following to change the billing day from this charge on.",
	}, (*Service).ChangeChargeDate}
	OpChangeChargeAmount = Op[ChangeChargeAmountInput, ChargeResult]{Meta{
		Name: "change_charge_amount", Title: "Change a charge amount", Tag: "Charges", Method: "POST", Path: "/charges/amount", Write: true, Idempotent: true,
		Description: "Changes the amount of a charge. scope=following (default) also changes the subscription price for later charges; scope=this only this charge.",
	}, (*Service).ChangeChargeAmount}
	OpSetChargeStatus = Op[SetChargeStatusInput, ChargeResult]{Meta{
		Name: "set_charge_status", Title: "Mark a charge paid, failed or skipped", Tag: "Charges", Method: "POST", Path: "/charges/status", Write: true, Idempotent: true,
		Description: "Resolves a charge: paid (optionally with the amount the bank actually took, in any currency), failed, skipped, or pending to reopen it.",
	}, (*Service).SetChargeStatus}
	OpDecideRenewal = Op[DecideRenewalInput, ChargeResult]{Meta{
		Name: "decide_renewal", Title: "Keep a renewal", Tag: "Charges", Method: "POST", Path: "/charges/decide", Write: true, Idempotent: true,
		Description: "For subscriptions with a notice period: records that the member wants to keep this renewal, so Exhale stops asking before the last day to cancel. To cancel instead, use set_subscription_status.",
	}, (*Service).DecideRenewal}
	OpPreviewSchedule = Op[PreviewScheduleInput, SchedulePreview]{Meta{
		Name: "preview_schedule", Title: "Preview charge dates", Tag: "Charges", Method: "POST", Path: "/schedule/preview", Idempotent: true,
		Description: "Computes the charge dates of a billing rule with Exhale's engine (month-end and leap-day aware) without saving anything.",
	}, (*Service).PreviewSchedule}
	OpSummary = Op[struct{}, SpendingSummary]{Meta{
		Name: "spending_summary", Title: "Spending summary", Tag: "Insights", Method: "GET", Path: "/summary",
		Description: "Monthly and yearly recurring spend per currency, spend per category and payment method, how much to set aside each month for yearly bills, what is due before the next payday, the charges of the next 7 days, and what cancelled subscriptions no longer cost per year (stopped).",
	}, (*Service).Summary}
	OpSpendHistory = Op[SpendingHistoryInput, SpendingHistory]{Meta{
		Name: "spending_history", Title: "Real spend per month", Tag: "Insights", Method: "GET", Path: "/spending/history",
		Description: "What was really paid each month (from the charge ledger, not from current prices), what is expected in the coming months, and lifetime paid per subscription.",
	}, (*Service).SpendHistory}
	OpBalances = Op[BalancesInput, Balances]{Meta{
		Name: "month_balances", Title: "Who owes whom", Tag: "Insights", Method: "GET", Path: "/balances",
		Description: "For subscriptions with cost shares: what each member paid and should carry in a month, and the fewest payments that settle it.",
	}, (*Service).MonthBalances}
	OpAudit = Op[struct{}, AuditReport]{Meta{
		Name: "audit", Title: "Audit the subscriptions", Tag: "Insights", Method: "GET", Path: "/audit",
		Description: "Facts worth acting on, computed not guessed: duplicates, price increases in the last year, free trials about to charge, last days to cancel, things about to expire, big charges coming, cards about to expire with the subscriptions that depend on them, unconfirmed charges and subscriptions marked as not used.",
	}, (*Service).Audit}
	OpSavePaymentMethod = Op[SavePaymentMethodInput, PaymentMethodOut]{Meta{
		Name: "save_payment_method", Title: "Create or update a payment method", Tag: "Household", Method: "POST", Path: "/payment-methods", Write: true,
		Description: "Creates a payment method (no id) or updates one (with id): name, kind, last four digits, expiry date and owner.",
	}, savePaymentMethod}
	OpReassignPaymentMethod = Op[ReassignPaymentMethodInput, ReassignResult]{Meta{
		Name: "reassign_payment_method", Title: "Move subscriptions to another payment method", Tag: "Household", Method: "POST", Path: "/payment-methods/reassign", Write: true, Idempotent: true,
		Description: "Moves every subscription paid with one method to another in one step, e.g. when a card is about to expire.",
	}, (*Service).ReassignPaymentMethod}
	OpDeletePaymentMethod = Op[DeletePaymentMethodInput, ReassignResult]{Meta{
		Name: "delete_payment_method", Title: "Delete a payment method", Tag: "Household", Method: "POST", Path: "/payment-methods/delete", Write: true, Destructive: true,
		Description: "Deletes a payment method. Pass moveTo so its subscriptions move to another method instead of being left without one.",
	}, (*Service).DeletePaymentMethod}
	OpUpdateCategory = Op[UpdateCategoryInput, CategoryOut]{Meta{
		Name: "update_category", Title: "Rename a category", Tag: "Household", Method: "POST", Path: "/categories/update", Write: true, Idempotent: true,
		Description: "Renames or recolours a category.",
	}, updateCategory}
	OpDeleteCategory = Op[DeleteCategoryInput, DeleteResult]{Meta{
		Name: "delete_category", Title: "Delete a category", Tag: "Household", Method: "POST", Path: "/categories/delete", Write: true, Destructive: true,
		Description: "Deletes a category; its subscriptions become uncategorized.",
	}, (*Service).DeleteCategory}
	OpCreateCategory = Op[CreateCategoryInput, CategoryOut]{Meta{
		Name: "create_category", Title: "Create a category", Tag: "Household", Method: "POST", Path: "/categories", Write: true,
		Description: "Adds a category for grouping subscriptions.",
	}, createCategory}
	OpExport = Op[struct{}, ExportData]{Meta{
		Name: "export_data", Title: "Export everything", Tag: "Data", Method: "GET", Path: "/export",
		Description: "Everything the household keeps in Exhale in one JSON document: settings, members, categories, payment methods and every subscription with all its charges.",
	}, (*Service).Export}
	OpImport = Op[ImportInput, ImportResult]{Meta{
		Name: "import_subscriptions", Title: "Import subscriptions", Tag: "Data", Method: "POST", Path: "/import", Write: true,
		Description: "Creates many subscriptions in one step, e.g. from a bank statement, a spreadsheet or another app. Categories, payment methods and payers are given by name (missing categories and payment methods are created). Items that duplicate an existing subscription or fail validation are skipped and reported. Always call it first with dryRun and show the user the list.",
	}, (*Service).Import}
	OpReminders = Op[ReminderLogInput, ReminderLog]{Meta{
		Name: "list_reminders", Title: "Reminder log", Tag: "Audit", Method: "GET", Path: "/reminders",
		Description: "Every reminder Exhale planned, newest first, with what happened to it: sent, scheduled, failed and why, or no longer needed. Answers \"why didn't I get that reminder?\".",
	}, (*Service).Reminders}
	OpListAudit = Op[AuditLogInput, AuditLog]{Meta{
		Name: "list_changes", Title: "Recent changes", Tag: "Audit", Method: "GET", Path: "/changes",
		Description: "Recent changes with who made them (member, agent or system). Each id can be passed to undo.",
	}, (*Service).ListAudit}
	// Statement matching (import from statements).
	OpMatchStatement = Op[MatchStatementInput, StatementMatch]{Meta{
		Name: "match_statement", Title: "Match a statement to expected charges", Tag: "Charges", Method: "POST", Path: "/charges/match", Idempotent: true,
		Description: "Compares the payments of a bank or card statement with the charges Exhale expects (merchant, date within a week, amount within 30%). Changes nothing. Returns the charges each payment confirms: for action mark_paid call set_charge_status with status=paid and the amount paid (dryRun first, then show the user); when priceChange is set, ask whether it is the new price. Also lists open charges due in the period that the statement does not show.",
	}, (*Service).MatchStatement}
	OpUndo = Op[UndoInput, UndoResult]{Meta{
		Name: "undo", Title: "Undo a change", Tag: "Audit", Method: "POST", Path: "/changes/undo", Write: true,
		Description: "Reverts a change by its auditId, restoring the previous state.",
	}, (*Service).Undo}

	// Reminders that end in an answer.
	OpSnooze = Op[SnoozeInput, SnoozeResult]{Meta{
		Name: "snooze_reminder", Title: "Remind me later", Tag: "Charges", Method: "POST", Path: "/charges/snooze", Write: true, Idempotent: true,
		Description: "Brings a reminder, a keep-or-cancel question or a was-it-charged question about a charge back in a few days at the reminder time. It never comes back too late: a reminder at the latest on the charge date, a keep-or-cancel question on the last day to cancel.",
	}, (*Service).Snooze}
	OpDismissDuplicate = Op[DismissDuplicateInput, DismissResult]{Meta{
		Name: "dismiss_duplicate", Title: "A duplicate is fine", Tag: "Insights", Method: "POST", Path: "/audit/duplicates/dismiss", Write: true, Idempotent: true,
		Description: "Records that a service paid more than once (a group from audit's duplicates, by its key) is on purpose: audit stops reporting it and nobody is messaged about it again.",
	}, (*Service).DismissDuplicate}
)

Use cases exposed to every adapter. Adding one here makes it available to AI agents; the parity test fails if an adapter misses one.

View Source
var (
	OpBulkUpdate = Op[BulkUpdateInput, BulkResult]{Meta{
		Name: "bulk_update_subscriptions", Title: "Change several subscriptions", Tag: "Subscriptions", Method: "POST", Path: "/subscriptions/bulk", Write: true, Destructive: true,
		Description: "Changes several subscriptions in one step, all or none: set the category or payment method, add or remove tags (action update), or pause, resume, cancel or delete them. Call it first with dryRun to show what changes for each. The auditIds it returns can all be passed to undo to revert the whole change.",
	}, (*Service).BulkUpdate}
	OpRenameTag = Op[RenameTagInput, BulkResult]{Meta{
		Name: "rename_tag", Title: "Rename a tag", Tag: "Subscriptions", Method: "POST", Path: "/tags/rename", Write: true, Idempotent: true,
		Description: "Renames a tag on every subscription that has it; if one already has the new name, they merge. The tags in use are in get_household.",
	}, (*Service).RenameTag}
	OpDeleteTag = Op[DeleteTagInput, BulkResult]{Meta{
		Name: "delete_tag", Title: "Delete a tag", Tag: "Subscriptions", Method: "POST", Path: "/tags/delete", Write: true, Destructive: true,
		Description: "Removes a tag from every subscription. The subscriptions stay. Can be reverted with undo (auditIds).",
	}, (*Service).DeleteTag}
	// Logos and documents (ADR 0027). Uploading and downloading a file are
	// plain HTTP endpoints, since they carry files, not JSON.
	OpListDocuments = Op[ListDocumentsInput, DocumentList]{Meta{
		Name: "list_documents", Title: "List a subscription's documents", Tag: "Documents", Method: "GET", Path: "/subscriptions/{id}/documents",
		Description: "The files attached to a subscription (contract, terms, invoices and receipts), newest first: name, kind, size, the charge an invoice belongs to and the path to download it.",
	}, (*Service).ListDocuments}
	OpDeleteDocument = Op[DeleteDocumentInput, DocumentResult]{Meta{
		Name: "delete_document", Title: "Delete a document", Tag: "Documents", Method: "POST", Path: "/documents/delete", Write: true, Destructive: true,
		Description: "Deletes a document attached to a subscription. It is kept 30 days and can be restored with undo.",
	}, (*Service).DeleteDocument}
		Name: "remove_logo", Title: "Remove an uploaded logo", Tag: "Documents", Method: "POST", Path: "/subscriptions/logo/remove", Write: true, Idempotent: true,
		Description: "Drops the logo someone uploaded for a subscription: it shows the bundled brand mark or its initials again. Can be reverted with undo.",
	}, (*Service).RemoveLogo}
	// Keep this computer awake (ADR 0028).
	OpGetKeepAwake = Op[struct{}, KeepAwakeInfo]{Meta{
		Name: "get_keep_awake", Title: "Is this computer kept awake", Tag: "Household", Method: "GET", Path: "/keep-awake",
		Description: "Whether Exhale keeps the computer it runs on from sleeping (so reminders go out and chat buttons answer): off, always, or between two times of day in the household time zone; whether the display may sleep; and the live status (active, waiting outside the hours, unavailable here and why, or the error the system gave).",
	}, (*Service).GetKeepAwake}
	OpSetKeepAwake = Op[SetKeepAwakeInput, KeepAwakeInfo]{Meta{
		Name: "set_keep_awake", Title: "Keep this computer awake", Tag: "Household", Method: "POST", Path: "/keep-awake", Write: true, Idempotent: true,
		Description: "Owners only. Keeps the computer Exhale runs on from sleeping: mode off, always, or hours (from and to, HH:MM in the household time zone; to earlier than from crosses midnight). keepDisplayOn keeps the screen on too. A laptop with its lid closed still sleeps. Can be reverted with undo.",
	}, (*Service).SetKeepAwake}
)

Tools for many subscriptions: bulk changes and tags (bulk.go).

View Source
var CardNoticeDays = 30

CardNoticeDays is how long before a card expires Exhale says so.

View Source
var DecisionDaysBefore = []int{7, 0}

DecisionDaysBefore is when to ask "keep or cancel?" before the last day to cancel a subscription with a notice period: a week ahead and on the day.

View Source
var ErrForbidden = errors.New("forbidden")

ErrForbidden is returned when the actor lacks the required scope.

View Source
var ErrNoDestination = errors.New("no linked chat")

ErrNoDestination is returned by a delivery when the member has nowhere to receive it: no chat or channel linked, or none of them working.

View Source
var ErrObsolete = errors.New("no longer needed")

ErrObsolete is returned by a delivery when the message has nothing left to say (the duplicate was resolved, the card renewed). The item is recorded as no longer needed instead of sent.

Functions

func CollectAudits

func CollectAudits(ctx context.Context) (context.Context, *[]AuditRef)

CollectAudits returns a context that records every change committed with it. Adapters that must offer undo for any use case (the in-app assistant) read the ids from the returned slice after the call, without each use case having to return them.

func DayRuleName

func DayRuleName(r engine.Rule) string

DayRuleName and ShiftName are the API's words for a rule's options.

func DuplicateKey

func DuplicateKey(subs []SubscriptionView) string

DuplicateKey identifies a group of subscriptions of the same service.

func NormalizeLogo(data []byte) ([]byte, error)

NormalizeLogo decodes a PNG, JPEG or WebP picture and returns it as a PNG that fits in LogoSize×LogoSize, keeping its proportions.

func ShiftName

func ShiftName(r engine.Rule) string

func SnoozeDay

func SnoozeDay(kind string, c ChargeView, today engine.Date, days int) (on engine.Date, clamped, ok bool)

SnoozeDay is the day a message snoozed for days comes back: never later than its limit. ok is false when it cannot come back after today.

func SnoozeLimit

func SnoozeLimit(kind string, c ChargeView) (engine.Date, bool)

SnoozeLimit is the last day a message about a charge is still useful: a reminder until the charge date, a "keep or cancel?" until the last day to cancel. ok is false when there is no limit ("was it charged?").

Types

type Actor

type Actor struct {
	Kind        ActorKind
	MemberID    string
	HouseholdID string
	Label       string // "Alex", "Claude Desktop (MCP)", "scheduler"
	Scope       Scope
}

Actor is the authenticated caller of a use case.

func MemberActor

func MemberActor(m domain.Member, scope Scope) Actor

MemberActor returns an actor for a household member.

func System

func System(householdID string) Actor

System is the actor used by the scheduler.

func (Actor) CanWrite

func (a Actor) CanWrite() bool

CanWrite reports whether the actor may change data.

type ActorKind

type ActorKind string

ActorKind says who performs an operation.

const (
	ActorMember ActorKind = "member"
	ActorAgent  ActorKind = "agent"
	ActorSystem ActorKind = "system"
)

type AddDocumentInput

type AddDocumentInput struct {
	SubscriptionID string
	ChargeID       string
	Kind           string
	Name           string
	Data           []byte
	DryRun         bool
}

AddDocumentInput attaches a file. Uploads come over HTTP (multipart); the content is not part of the op catalog.

type AgentContext

type AgentContext struct {
	Markdown string `json:"markdown" jsonschema:"human-readable briefing of the household, its subscriptions and how Exhale works"`
}

AgentContext is the ready-made briefing an AI agent reads first (ADR 0018).

type AttentionList

type AttentionList struct {
	Unconfirmed            []ChargeView           `json:"unconfirmed" jsonschema:"charges whose due date passed and that are not marked paid, failed or skipped"`
	ExpiringPaymentMethods []domain.PaymentMethod `json:"expiringPaymentMethods" jsonschema:"payment methods expiring within 45 days"`
	Deadlines              []ChargeView           `` /* 128-byte string literal not displayed */
	// Delivery reports reminders that could not be delivered lately, so a
	// broken channel does not go unnoticed.
	Delivery *DeliveryProblem `json:"delivery,omitempty" jsonschema:"set when reminders failed to be delivered in the last 7 days"`
}

AttentionList is what needs the user's attention.

type AuditEntry

type AuditEntry struct {
	ID         string `json:"id"`
	At         string `json:"at"`
	ActorKind  string `json:"actorKind"`
	ActorLabel string `json:"actor"`
	Action     string `json:"action"`
	Entity     string `json:"entity"`
	EntityID   string `json:"entityId"`
	UndoneBy   string `json:"undoneBy,omitempty"`
}

AuditEntry is one recorded change.

type AuditLog

type AuditLog struct {
	Entries []AuditEntry `json:"entries"`
}

AuditLog is the list of recent changes.

type AuditLogInput

type AuditLogInput struct {
	Limit int `json:"limit,omitempty" query:"limit" jsonschema:"default 50" maximum:"500"`
}

AuditLogInput limits the audit log.

type AuditRef

type AuditRef struct {
	ID       string `json:"id"`
	Action   string `json:"action"`
	Entity   string `json:"entity"`
	EntityID string `json:"entityId"`
}

AuditRef identifies a change recorded in the audit log.

type AuditReport

type AuditReport struct {
	Duplicates      []DuplicateGroup     `` /* 128-byte string literal not displayed */
	PriceIncreases  []PriceIncrease      `json:"priceIncreases" jsonschema:"prices that went up in the last 12 months"`
	TrialsEnding    []ChargeView         `json:"trialsEnding" jsonschema:"free trials that turn into a charge within 14 days"`
	CancelDeadlines []ChargeView         `json:"cancelDeadlines" jsonschema:"last days to cancel within 30 days that have no decision yet"`
	Expiring        []ChargeView         `json:"expiring" jsonschema:"manual renewals that expire within 60 days"`
	BigCharges      []ChargeView         `json:"bigCharges" jsonschema:"yearly or rarer charges due within 30 days"`
	CardsExpiring   []CardImpact         `json:"cardsExpiring" jsonschema:"payment methods expiring within 45 days and the subscriptions that use them"`
	Unconfirmed     []ChargeView         `json:"unconfirmed" jsonschema:"charges whose date passed without being confirmed"`
	NotUsed         []UnusedSubscription `json:"notUsed" jsonschema:"subscriptions the member said they no longer use"`
	Findings        int                  `json:"findings" jsonschema:"total number of findings"`
	CheaperPlans    []CheaperPlan        `json:"cheaperPlans" jsonschema:"subscriptions where the member recorded another price (e.g. yearly) that costs less per year"`
}

AuditReport lists facts worth acting on. Everything here is computed, not guessed: an agent can turn it into advice.

type Balances

type Balances struct {
	Month        string          `json:"month"`
	MainCurrency string          `json:"mainCurrency"`
	Members      []MemberBalance `json:"members"`
	Transfers    []Transfer      `json:"transfers" jsonschema:"the fewest payments that settle the month"`
	Charges      []SharedCharge  `json:"charges" jsonschema:"the split charges behind the numbers (paid and still pending this month)"`
}

Balances says who owes whom for a month of shared subscriptions.

type BalancesInput

type BalancesInput struct {
	Month string `json:"month,omitempty" query:"month" jsonschema:"YYYY-MM, default the current month"`
}

BalancesInput selects the month to settle.

type Binding

type Binding struct {
	Meta
	In, Out reflect.Type
	Invoke  func(s *Service, ctx context.Context, a Actor, raw json.RawMessage) (any, error)
}

Binding is the untyped view of an Op, for adapters that work with JSON.

func Catalog

func Catalog() []Binding

Catalog lists every use case exposed to adapters.

type BootstrapInput

type BootstrapInput struct {
	HouseholdName string
	OwnerName     string
	OwnerEmail    string
	Currency      string
	Timezone      string
	Locale        string
}

BootstrapInput creates the household on first run.

type Breakdown

type Breakdown struct {
	Period      string         `json:"period" enum:"month,year"`
	By          string         `json:"by" enum:"category,payment_method,subscription"`
	Total       money.Money    `json:"total" doc:"in the main currency"`
	Rows        []BreakdownRow `json:"rows" doc:"largest first"`
	Converted   bool           `json:"converted" doc:"other currencies were converted at the latest ECB reference rate"`
	Unconverted []string       `json:"unconverted" doc:"currencies without a known rate, left out"`
}

Breakdown is what active subscriptions cost per month or per year, in the main currency, grouped by category, payment method or subscription. The assistant shows it as a screen (ADR 0019 §4); it is not an op of its own: get_summary answers the same question for agents.

type BreakdownRow

type BreakdownRow struct {
	Label          string      `json:"label"`
	SubscriptionID string      `json:"subscriptionId,omitempty" doc:"when the breakdown is by subscription"`
	Amount         money.Money `json:"amount"`
	Count          int         `json:"count"`
}

BreakdownRow is one group. Label is empty for subscriptions without a category or a payment method: the screen names that in its language.

type BulkItem

type BulkItem struct {
	ID      string        `json:"id"`
	Name    string        `json:"name"`
	Changes []FieldChange `json:"changes" jsonschema:"empty: nothing changes for this one"`
}

BulkItem is what happens (or would happen) to one subscription.

type BulkResult

type BulkResult struct {
	Items    []BulkItem `json:"items"`
	Changed  int        `json:"changed" jsonschema:"subscriptions that change"`
	AuditIDs []string   `json:"auditIds" jsonschema:"pass them all to undo (auditIds) to revert the whole change; empty in a dry run"`
	DryRun   bool       `json:"dryRun,omitempty"`
}

BulkResult reports a change to several subscriptions.

type BulkUpdateInput

type BulkUpdateInput struct {
	IDs             []string `json:"ids" jsonschema:"subscription ids (sub_…), at most 500" minItems:"1" maxItems:"500"`
	Action          string   `` /* 234-byte string literal not displayed */
	CategoryID      *string  `json:"categoryId,omitempty" jsonschema:"with update: the new category; empty string removes it"`
	PaymentMethodID *string  `json:"paymentMethodId,omitempty" jsonschema:"with update: the new payment method; empty string removes it"`
	AddTags         []string `json:"addTags,omitempty" jsonschema:"with update: tags to add to each"`
	RemoveTags      []string `json:"removeTags,omitempty" jsonschema:"with update: tags to remove from each"`
	DryRun          bool     `json:"dryRun,omitempty" jsonschema:"preview what changes for each subscription; nothing is saved"`
}

BulkUpdateInput changes many subscriptions in one step.

type CalendarEvent

type CalendarEvent struct {
	Date           engine.Date `json:"date"`
	Kind           string      `` /* 158-byte string literal not displayed */
	Title          string      `json:"title"`
	SubscriptionID string      `json:"subscriptionId"`
	Subscription   string      `json:"subscription"`
	Amount         money.Money `json:"amount"`
	ChargeID       string      `json:"chargeId,omitempty" jsonschema:"empty for dates projected from the schedule beyond the next few months"`
	Status         string      `json:"status,omitempty" jsonschema:"charge status; projected dates are pending"`
	Projected      bool        `json:"projected" jsonschema:"computed from the schedule; the charge does not exist yet"`
}

CalendarEvent is one dated thing worth seeing in a calendar.

type CalendarInput

type CalendarInput struct {
	From string `json:"from,omitempty" query:"from" jsonschema:"first day, YYYY-MM-DD (default today)"`
	To   string `json:"to,omitempty" query:"to" jsonschema:"last day, YYYY-MM-DD (default one year after from; at most three years)"`
}

CalendarInput selects the dates to show.

type CalendarView

type CalendarView struct {
	From   engine.Date     `json:"from"`
	To     engine.Date     `json:"to"`
	Events []CalendarEvent `json:"events"`
}

CalendarView lists the events of a period.

type CardImpact

type CardImpact struct {
	PaymentMethod domain.PaymentMethod `json:"paymentMethod"`
	Subscriptions []SubscriptionRef    `json:"subscriptions" jsonschema:"active subscriptions that will fail when it expires"`
}

CardImpact is a payment method about to expire and what depends on it.

type CardNotice

type CardNotice struct {
	PaymentMethod domain.PaymentMethod
	DaysLeft      int
	Subscriptions []SubscriptionView
	// Others are the payment methods they can move to.
	Others []domain.PaymentMethod
}

CardNotice is a payment method about to expire with the subscriptions that use it, as the recipient sees them.

type CategoryOut

type CategoryOut struct {
	Category domain.Category `json:"category"`
}

CategoryOut wraps a created category.

type CategorySpend

type CategorySpend struct {
	Category string      `json:"category"`
	Monthly  money.Money `json:"monthly" jsonschema:"in the main currency"`
	Count    int         `json:"count"`
}

CategorySpend is the monthly spend of one category.

type ChangeChargeAmountInput

type ChangeChargeAmountInput struct {
	ChargeID string `json:"chargeId"`
	Amount   string `json:"amount" jsonschema:"new amount as a decimal string, in the subscription currency"`
	Scope    string `` /* 164-byte string literal not displayed */
	DryRun   bool   `json:"dryRun,omitempty"`
}

ChangeChargeAmountInput changes what a charge costs.

type ChangeChargeDateInput

type ChangeChargeDateInput struct {
	ChargeID string `json:"chargeId" jsonschema:"charge id (chg_…)"`
	NewDate  string `json:"newDate" jsonschema:"new due date, YYYY-MM-DD"`
	Scope    string `` /* 149-byte string literal not displayed */
	DryRun   bool   `json:"dryRun,omitempty"`
}

ChangeChargeDateInput moves a charge.

type ChargeList

type ChargeList struct {
	From    engine.Date  `json:"from"`
	To      engine.Date  `json:"to"`
	Charges []ChargeView `json:"charges"`
}

ChargeList is a list of charges.

type ChargeMatch

type ChargeMatch struct {
	Charge      ChargeView    `json:"charge"`
	Transaction StatementLine `json:"transaction"`
	Paid        money.Money   `json:"paid" jsonschema:"what the transaction took"`
	Action      string        `` /* 196-byte string literal not displayed */
	// PriceChange is set when the bank took another amount in the same
	// currency: ask whether it is the new price.
	PriceChange *PriceChangeHint `` /* 194-byte string literal not displayed */
}

ChargeMatch is an expected charge and the transaction that paid it.

type ChargeResult

type ChargeResult struct {
	Charge       ChargeView       `json:"charge"`
	Subscription SubscriptionView `json:"subscription"`
	AuditID      string           `json:"auditId,omitempty" jsonschema:"pass to undo to revert this change"`
	DryRun       bool             `json:"dryRun,omitempty"`
	// PriceChange is set when a charge was paid at a different amount than
	// expected, in the same currency: probably a new price.
	PriceChange *PriceChangeHint `` /* 175-byte string literal not displayed */
}

ChargeResult is returned by every charge edit.

type ChargeView

type ChargeView struct {
	domain.Charge
	SubscriptionName string `json:"subscriptionName"`
	PaymentMethod    string `json:"paymentMethod,omitempty"`
	Delayed          bool   `json:"delayed" jsonschema:"true when the charge was moved from its scheduled date"`
	DaysUntil        int    `json:"daysUntil" jsonschema:"days from today to the due date (negative if overdue)"`
	// Kind tells what this date means to the member.
	Kind         string      `` /* 190-byte string literal not displayed */
	CancelBy     engine.Date `json:"cancelBy,omitzero" jsonschema:"last day to cancel before this charge, when the subscription has a notice period"`
	CancelPassed bool        `json:"cancelPassed" jsonschema:"the last day to cancel before this charge has passed: it will renew"`
}

ChargeView is a charge with its subscription context.

type CheaperPlan

type CheaperPlan struct {
	Subscription SubscriptionRef `json:"subscription"`
	Other        domain.AltPrice `json:"other" jsonschema:"the other price and the period it covers"`
	PlanSwitch
}

CheaperPlan is another price the member recorded that would cost less.

type CreateCategoryInput

type CreateCategoryInput struct {
	Name   string `json:"name"`
	Color  string `json:"color,omitempty" jsonschema:"hex color, e.g. #7c6cff"`
	DryRun bool   `json:"dryRun,omitempty"`
}

CreateCategoryInput creates a category.

type CreateSubscriptionInput

type CreateSubscriptionInput struct {
	Name            string `json:"name" jsonschema:"service name, e.g. Netflix Premium" minLength:"1" maxLength:"120"`
	Price           string `json:"price" jsonschema:"price per charge as a decimal string, e.g. 12.99"`
	Currency        string `json:"currency" jsonschema:"ISO 4217 code, e.g. EUR"`
	Interval        int    `json:"interval,omitempty" jsonschema:"number of units between charges, default 1" minimum:"1"`
	Unit            string `json:"unit,omitempty" jsonschema:"day, week, month (default) or year" enum:"day,week,month,year"`
	FirstCharge     string `` /* 126-byte string literal not displayed */
	DayRule         string `` /* 470-byte string literal not displayed */
	WeekendShift    string `` /* 343-byte string literal not displayed */
	CategoryID      string `json:"categoryId,omitempty"`
	PaymentMethodID string `json:"paymentMethodId,omitempty"`
	PayerID         string `json:"payerId,omitempty" jsonschema:"member who pays; defaults to the caller"`
	Private         bool   `json:"private,omitempty" jsonschema:"only visible to its creator and payer"`
	URL             string `json:"url,omitempty"`
	Notes           string `json:"notes,omitempty"`
	AutoConfirm     bool   `json:"autoConfirm,omitempty" jsonschema:"mark each charge paid automatically on its due date"`
	ReminderDays    []int  `json:"reminderDays,omitempty" jsonschema:"days before each charge to remind, default [3, 0]"`
	EndDate         string `json:"endDate,omitempty" jsonschema:"last possible charge date, YYYY-MM-DD"`
	Status          string `` /* 210-byte string literal not displayed */
	TermsInput
	ExtrasInput
	DryRun bool `json:"dryRun,omitempty" jsonschema:"preview the result without saving"`
}

CreateSubscriptionInput describes a new subscription.

type DecideRenewalInput

type DecideRenewalInput struct {
	ChargeID string `json:"chargeId"`
	Decision string `json:"decision" jsonschema:"keep: stop asking before the last day to cancel; undecided: ask again" enum:"keep,undecided"`
	DryRun   bool   `json:"dryRun,omitempty"`
}

DecideRenewalInput records what the member decided about a renewal.

type DeleteCategoryInput

type DeleteCategoryInput struct {
	ID     string `json:"id" jsonschema:"category id (cat_…)"`
	DryRun bool   `json:"dryRun,omitempty"`
}

DeleteCategoryInput removes a category.

type DeleteDocumentInput

type DeleteDocumentInput struct {
	ID     string `json:"id" jsonschema:"document id (doc_…)"`
	DryRun bool   `json:"dryRun,omitempty"`
}

DeleteDocumentInput names the document.

type DeletePaymentMethodInput

type DeletePaymentMethodInput struct {
	ID     string `json:"id" jsonschema:"payment method to delete (pm_…)"`
	MoveTo string `json:"moveTo,omitempty" jsonschema:"payment method that takes over its subscriptions; empty leaves them without one"`
	DryRun bool   `json:"dryRun,omitempty"`
}

DeletePaymentMethodInput removes a payment method.

type DeleteResult

type DeleteResult struct {
	Deleted string `json:"deleted"`
	AuditID string `json:"auditId" jsonschema:"pass to undo to restore"`
}

DeleteResult confirms a deletion.

type DeleteSubscriptionInput

type DeleteSubscriptionInput struct {
	ID     string `json:"id"`
	DryRun bool   `json:"dryRun,omitempty"`
}

DeleteSubscriptionInput permanently deletes a subscription and its charges.

type DeleteTagInput

type DeleteTagInput struct {
	Tag    string `json:"tag" jsonschema:"the tag to remove (case does not matter)"`
	DryRun bool   `json:"dryRun,omitempty"`
}

DeleteTagInput removes a tag from every subscription.

type DeliveryProblem

type DeliveryProblem struct {
	Failed    int    `json:"failed" jsonschema:"reminders not delivered in the last 7 days"`
	NoChannel int    `json:"noChannel" jsonschema:"of those, how many had nowhere to go (no chat or channel linked)"`
	LastError string `json:"lastError,omitempty" jsonschema:"most recent delivery error"`
}

DeliveryProblem says reminders are not arriving.

type DismissDuplicateInput

type DismissDuplicateInput struct {
	Key    string `json:"key" jsonschema:"key of the duplicate group, from audit"`
	DryRun bool   `json:"dryRun,omitempty"`
}

DismissDuplicateInput says a duplicate is fine.

type DismissResult

type DismissResult struct {
	Key    string `json:"key"`
	DryRun bool   `json:"dryRun,omitempty"`
}

DismissResult confirms a finding was set aside.

type Document

type Document struct {
	ID             string    `json:"id"`
	SubscriptionID string    `json:"subscriptionId"`
	ChargeID       string    `json:"chargeId,omitempty" jsonschema:"the charge this invoice or receipt belongs to, if any"`
	Kind           string    `` /* 144-byte string literal not displayed */
	Name           string    `json:"name" jsonschema:"file name as uploaded"`
	MediaType      string    `json:"mediaType" jsonschema:"application/pdf, image/png, image/jpeg or image/webp"`
	Size           int64     `json:"size" jsonschema:"bytes"`
	SHA256         string    `json:"sha256"`
	CreatedAt      time.Time `json:"createdAt"`
	CreatedBy      string    `json:"createdBy" jsonschema:"who attached it"`
	// URL is where the content is downloaded, with the caller's credentials.
	URL string `json:"url" jsonschema:"path to download the file, under this Exhale's address"`
	// Path is the file inside a full export archive; empty elsewhere.
	Path string `json:"path,omitempty"`
}

Document is a file attached to a subscription, without its content.

type DocumentList

type DocumentList struct {
	Documents []Document `json:"documents"`
}

DocumentList is the documents of one subscription, newest first.

type DocumentResult

type DocumentResult struct {
	Document Document `json:"document"`
	AuditID  string   `json:"auditId,omitempty" jsonschema:"pass to undo to revert"`
}

DocumentResult is a document after a change, with the audit id for undo.

type DuplicateGroup

type DuplicateGroup struct {
	Key           string            `json:"key" jsonschema:"stable id of the group; pass it to dismiss_duplicate when paying twice is on purpose"`
	Service       string            `json:"service"`
	Subscriptions []SubscriptionRef `json:"subscriptions"`
}

DuplicateGroup is the same service paid more than once.

type DuplicateNotice

type DuplicateNotice struct {
	Group DuplicateGroup
	// Subscriptions in the same order as Group.Subscriptions.
	Subscriptions []SubscriptionView
}

DuplicateNotice is the group a duplicate message is about, as the recipient sees it.

type ExportData

type ExportData struct {
	Format         string                 `json:"format" jsonschema:"always exhale/1"`
	ExportedAt     time.Time              `json:"exportedAt"`
	Household      domain.Household       `json:"household"`
	Members        []domain.Member        `json:"members"`
	Categories     []domain.Category      `json:"categories"`
	PaymentMethods []domain.PaymentMethod `json:"paymentMethods"`
	Subscriptions  []ExportedSubscription `json:"subscriptions"`
}

ExportData is everything the household keeps in Exhale, in one document.

type ExportedSubscription

type ExportedSubscription struct {
	SubscriptionView
	Charges []domain.Charge `json:"charges"`
	// Files are its uploaded logo and documents, without their content:
	// the full export archive and the backups carry the content (ADR 0027).
	Files []Document `json:"files"`
}

ExportedSubscription is a subscription with every charge it has had.

type ExtrasInput

type ExtrasInput struct {
	Tags     []string `json:"tags,omitempty" jsonschema:"free labels, many per subscription, e.g. work or family. Totals stay by category"`
	AltPrice string   `` /* 225-byte string literal not displayed */
	AltUnit  string   `json:"altUnit,omitempty" jsonschema:"the period altPrice covers: month or year (default: year)" enum:"month,year"`
}

ExtrasInput are the tags and the alternative price of a new subscription.

type ExtrasUpdate

type ExtrasUpdate struct {
	Tags     []string `json:"tags,omitempty" jsonschema:"replaces every tag; an empty list removes them all"`
	AltPrice *string  `` /* 164-byte string literal not displayed */
	AltUnit  *string  `json:"altUnit,omitempty" jsonschema:"the period altPrice covers: month or year" enum:"month,year"`
}

ExtrasUpdate changes the tags or the alternative price.

type FieldChange

type FieldChange struct {
	Field string `json:"field" jsonschema:"category, paymentMethod, tags, status or deleted" enum:"category,paymentMethod,tags,status,deleted"`
	From  string `json:"from"`
	To    string `json:"to"`
}

FieldChange is one field that changes on one subscription.

type GetSubscriptionInput

type GetSubscriptionInput struct {
	ID string `json:"id" path:"id" jsonschema:"subscription id (sub_…)"`
}

GetSubscriptionInput identifies a subscription.

type HouseholdInfo

type HouseholdInfo struct {
	Household      domain.Household       `json:"household"`
	Members        []domain.Member        `json:"members"`
	Categories     []domain.Category      `json:"categories"`
	PaymentMethods []domain.PaymentMethod `json:"paymentMethods"`
	Today          engine.Date            `json:"today" jsonschema:"today's date in the household time zone"`
	Tags           []TagCount             `json:"tags" jsonschema:"every tag in use, with how many subscriptions carry it"`
}

HouseholdInfo is the household with its members, categories and payment methods.

func (HouseholdInfo) LanguageOf

func (i HouseholdInfo) LanguageOf(memberID string) string

LanguageOf returns the language to speak to a member: their own, else the household's.

type ImportInput

type ImportInput struct {
	Items           []ImportItem `json:"items"`
	AllowDuplicates bool         `` /* 131-byte string literal not displayed */
	DryRun          bool         `json:"dryRun,omitempty" jsonschema:"preview: nothing is saved. Show the result to the user, then call again without it"`
}

ImportInput brings in several subscriptions at once.

type ImportItem

type ImportItem struct {
	Name          string   `json:"name"`
	Price         string   `json:"price" jsonschema:"decimal string, e.g. 12.99"`
	Currency      string   `json:"currency,omitempty" jsonschema:"ISO 4217 code; default: the household's display currency"`
	Interval      int      `json:"interval,omitempty" jsonschema:"units between charges, default 1"`
	Unit          string   `json:"unit,omitempty" jsonschema:"day, week, month (default) or year" enum:"day,week,month,year"`
	NextCharge    string   `json:"nextCharge,omitempty" jsonschema:"next (or first) charge, YYYY-MM-DD. For an ended subscription: when it started"`
	DayRule       string   `` /* 240-byte string literal not displayed */
	WeekendShift  string   `` /* 202-byte string literal not displayed */
	StartedOn     string   `json:"startedOn,omitempty" jsonschema:"when the member first subscribed, YYYY-MM-DD"`
	EndDate       string   `json:"endDate,omitempty" jsonschema:"last day, for subscriptions that ended, YYYY-MM-DD"`
	Status        string   `json:"status,omitempty" jsonschema:"active (default), paused or cancelled" enum:"active,paused,cancelled"`
	Category      string   `json:"category,omitempty" jsonschema:"category name; created if it does not exist"`
	PaymentMethod string   `json:"paymentMethod,omitempty" jsonschema:"payment method name; created if it does not exist"`
	Payer         string   `json:"payer,omitempty" jsonschema:"member name; default: the caller"`
	URL           string   `json:"url,omitempty"`
	Notes         string   `json:"notes,omitempty"`
	CancelURL     string   `json:"cancelUrl,omitempty"`
	Account       string   `json:"account,omitempty"`
	Renewal       string   `json:"renewal,omitempty" jsonschema:"auto (default) or manual" enum:"auto,manual"`
	NoticeDays    int      `json:"noticeDays,omitempty"`
	TrialEndsOn   string   `json:"trialEndsOn,omitempty"`
	ReminderDays  []int    `json:"reminderDays,omitempty" jsonschema:"days before each charge to remind; default [3, 0]"`
	AutoConfirm   bool     `json:"autoConfirm,omitempty"`
	Tags          []string `json:"tags,omitempty" jsonschema:"free labels"`
	AltPrice      string   `json:"altPrice,omitempty" jsonschema:"another price the provider offers, decimal string, e.g. 99.00"`
	AltUnit       string   `json:"altUnit,omitempty" jsonschema:"the period altPrice covers: month or year" enum:"month,year"`
	// Remark is a note from whoever prepared the item (a file parser or an
	// agent) for the person reviewing it. It is not saved.
	Remark string `json:"remark,omitempty" jsonschema:"note for the person reviewing the import; not saved"`
}

ImportItem is one subscription to bring in. Categories, payment methods and payers are given by name, so a spreadsheet, another app's export or an AI agent reading a bank statement can fill it without knowing ids.

type ImportOutcome

type ImportOutcome struct {
	Name    string `json:"name"`
	Action  string `json:"action" jsonschema:"create, duplicate (skipped) or error (skipped)" enum:"create,duplicate,error"`
	ID      string `json:"id,omitempty" jsonschema:"new subscription id; not saved in a dry run"`
	Message string `json:"message,omitempty"`
}

ImportOutcome is what happened (or would happen) to one item.

type ImportResult

type ImportResult struct {
	Created           int             `json:"created"`
	Duplicates        int             `json:"duplicates"`
	Errors            int             `json:"errors"`
	Items             []ImportOutcome `json:"items"`
	NewCategories     []string        `json:"newCategories" jsonschema:"categories created along the way"`
	NewPaymentMethods []string        `json:"newPaymentMethods" jsonschema:"payment methods created along the way"`
	DryRun            bool            `json:"dryRun,omitempty"`
}

ImportResult summarises an import.

type KeepAwake

type KeepAwake struct {

	// MaxSleep bounds every wait, so a change made by another process
	// (exhale mcp) or a clock jump is noticed.
	MaxSleep time.Duration
	// RetryAfter is how long a refused hold waits before it is tried again
	// with the same setting.
	RetryAfter time.Duration
	// contains filtered or unexported fields
}

KeepAwake applies the keep-awake setting with a Keeper: when the server starts, on every change and at the edges of the hours. It is a supervised service of exhale serve.

func NewKeepAwake

func NewKeepAwake(svc *Service, keeper awake.Keeper, log *slog.Logger) *KeepAwake

NewKeepAwake returns the controller and attaches it to svc, whose keep-awake operations then report its status.

func (*KeepAwake) Apply

func (k *KeepAwake) Apply(ctx context.Context) time.Time

Apply holds or releases the machine as the setting asks now, and returns when that next changes by itself (zero: not by itself).

func (*KeepAwake) Release

func (k *KeepAwake) Release()

Release lets the machine sleep again.

func (*KeepAwake) Serve

func (k *KeepAwake) Serve(ctx context.Context) error

Serve applies the setting until ctx is cancelled, then lets the machine sleep again.

func (*KeepAwake) Status

func (k *KeepAwake) Status() KeepAwakeStatus

Status is the state after the last Apply.

func (*KeepAwake) String

func (k *KeepAwake) String() string

type KeepAwakeInfo

type KeepAwakeInfo struct {
	KeepAwakeSetting
	Timezone string          `json:"timezone" jsonschema:"the time zone of from and to (the household's)"`
	Status   KeepAwakeStatus `json:"status"`
}

KeepAwakeInfo is the setting with its live status.

type KeepAwakeSetting

type KeepAwakeSetting struct {
	Mode string `` /* 153-byte string literal not displayed */
	From string `json:"from" jsonschema:"start of the hours, HH:MM in the household time zone"`
	To   string `json:"to" jsonschema:"end of the hours, HH:MM in the household time zone; earlier than from for hours that cross midnight"`
	// The system is kept awake; the display may still sleep unless this is set.
	KeepDisplayOn bool `json:"keepDisplayOn" jsonschema:"also keep the display on (default false: only the system stays awake)"`
}

KeepAwakeSetting is when Exhale keeps the computer it runs on awake.

type KeepAwakeStatus

type KeepAwakeStatus struct {
	State string `` /* 271-byte string literal not displayed */
	// Why it is unavailable.
	Reason string `` /* 163-byte string literal not displayed */
	Error  string `json:"error,omitempty" jsonschema:"what the system said, in its words"`
}

KeepAwakeStatus says whether the computer is kept awake right now.

type ListChargesInput

type ListChargesInput struct {
	From string `json:"from,omitempty" query:"from" jsonschema:"first due date, YYYY-MM-DD (default today)"`
	To   string `json:"to,omitempty" query:"to" jsonschema:"last due date, YYYY-MM-DD (default from + 30 days)"`
}

ListChargesInput selects charges by due date.

type ListDocumentsInput

type ListDocumentsInput struct {
	ID string `json:"id" path:"id" jsonschema:"subscription id (sub_…)"`
}

ListDocumentsInput names the subscription.

type ListSubscriptionsInput

type ListSubscriptionsInput struct {
	Status string `json:"status,omitempty" query:"status" jsonschema:"active, paused or cancelled; empty for all"`
	Query  string `json:"query,omitempty" query:"query" jsonschema:"case-insensitive search in the name, notes, account, category and tags"`
	Tag    string `json:"tag,omitempty" query:"tag" jsonschema:"only subscriptions with this tag (case-insensitive)"`
}

ListSubscriptionsInput filters subscriptions.

type LogoResult

type LogoResult struct {
	SubscriptionID string `json:"subscriptionId"`
	AuditID        string `json:"auditId,omitempty" jsonschema:"pass to undo to revert"`
}

LogoResult is the logo after a change.

type MatchStatementInput

type MatchStatementInput struct {
	Transactions []StatementLine `json:"transactions" jsonschema:"the payments of the statement"`
	From         string          `json:"from,omitempty" jsonschema:"first day the statement covers, YYYY-MM-DD (default: the first transaction)"`
	To           string          `json:"to,omitempty" jsonschema:"last day the statement covers, YYYY-MM-DD (default: the last transaction)"`
}

MatchStatementInput is a statement to compare with the expected charges.

type MemberBalance

type MemberBalance struct {
	MemberID string      `json:"memberId"`
	Name     string      `json:"name"`
	Paid     money.Money `json:"paid" jsonschema:"what this member's payment methods paid for shared subscriptions"`
	Share    money.Money `json:"share" jsonschema:"what this member should carry"`
	Net      money.Money `json:"net" jsonschema:"paid minus share: positive is owed money, negative owes money"`
}

MemberBalance is one member's position for the month.

type Meta

type Meta struct {
	Name        string // tool name, snake_case
	Title       string
	Description string
	Write       bool // changes data; needs write scope
	Destructive bool // deletes or cancels; agents need confirmation
	Idempotent  bool // repeating the call has no further effect
	Method      string
	Path        string // REST path under /api/v1
	Tag         string
}

Meta describes a use case for every adapter that exposes it: the REST API (huma), the MCP server and the in-app assistant (ADR 0018).

type MethodSpend

type MethodSpend struct {
	PaymentMethod string      `json:"paymentMethod"`
	Monthly       money.Money `json:"monthly" jsonschema:"in the main currency"`
	Count         int         `json:"count"`
}

MethodSpend is the monthly spend carried by one payment method.

type MonthSpend

type MonthSpend struct {
	Month    string      `json:"month" jsonschema:"YYYY-MM"`
	Paid     money.Money `json:"paid" jsonschema:"charges marked paid that month, in the display currency"`
	Expected money.Money `` /* 127-byte string literal not displayed */
	Count    int         `json:"count" jsonschema:"paid plus expected charges"`
}

MonthSpend is the money of one calendar month.

type Op

type Op[In, Out any] struct {
	Meta
	Run func(s *Service, ctx context.Context, a Actor, in In) (Out, error)
}

Op is a typed use case.

func (Op[In, Out]) Binding

func (o Op[In, Out]) Binding() Binding

Binding returns the untyped view of o.

type OutboxItem

type OutboxItem struct {
	ID       string
	Kind     string
	ChargeID string
	MemberID string
	DueAt    time.Time
	Attempts int
	// Ref is what an item that is not about a charge is about: a payment
	// method (KindCardExpiry) or a duplicate group (KindDuplicate).
	Ref string
	// Personal is set for an item one member asked for (a snooze): it goes
	// to them only, never to a shared team channel.
	Personal bool
}

OutboxItem is something the scheduler must deliver now.

type PaidTotals

type PaidTotals struct {
	Count int         `json:"count" jsonschema:"charges marked paid"`
	Total money.Money `json:"total" jsonschema:"sum of paid charges, in the subscription currency"`
	Since engine.Date `json:"since,omitzero" jsonschema:"date of the first paid charge"`
}

PaidTotals is what a subscription has really cost so far.

type PaydayOutlook

type PaydayOutlook struct {
	Payday  engine.Date  `json:"payday" jsonschema:"next payday"`
	Due     money.Money  `json:"due" jsonschema:"total of the charges due before it, in the display currency"`
	Charges []ChargeView `json:"charges"`
}

PaydayOutlook is what must be paid before the next payday.

type PaymentMethodOut

type PaymentMethodOut struct {
	PaymentMethod domain.PaymentMethod `json:"paymentMethod"`
}

PaymentMethodOut wraps a saved payment method.

type PlanSwitch

type PlanSwitch struct {
	Current money.Money `json:"currentYearly" jsonschema:"what the current plan costs per year"`
	Other   money.Money `json:"otherYearly" jsonschema:"what the other price costs per year"`
	// Saving is positive when switching saves money, negative when the
	// current plan is already the cheaper one.
	Saving money.Money `` /* 155-byte string literal not displayed */
}

PlanSwitch compares the current plan with the other price the member recorded.

type PreviewScheduleInput

type PreviewScheduleInput struct {
	Interval     int    `json:"interval" minimum:"1"`
	Unit         string `json:"unit" enum:"day,week,month,year"`
	FirstCharge  string `json:"firstCharge" jsonschema:"YYYY-MM-DD"`
	DayRule      string `` /* 470-byte string literal not displayed */
	WeekendShift string `` /* 343-byte string literal not displayed */
	Count        int    `json:"count,omitempty" jsonschema:"how many dates, default 12" maximum:"60"`
}

PreviewScheduleInput describes a billing rule to preview.

type PriceChangeHint

type PriceChangeHint struct {
	Expected    money.Money `json:"expected"`
	Charged     money.Money `json:"charged"`
	Change      string      `json:"changePercent"`
	YearlyDelta money.Money `json:"yearlyDelta" jsonschema:"difference per year if this is the new price, in the subscription currency"`
}

PriceChangeHint says a charge was paid at another amount.

type PriceIncrease

type PriceIncrease struct {
	Subscription SubscriptionRef `json:"subscription"`
	From         money.Money     `json:"from"`
	To           money.Money     `json:"to"`
	Since        engine.Date     `json:"since"`
	Change       string          `json:"changePercent"`
	YearlyDelta  money.Money     `json:"yearlyDelta" jsonschema:"extra cost per year at the new price, in the subscription currency"`
}

PriceIncrease is a price that went up in the last year.

type PricePoint

type PricePoint struct {
	From      engine.Date `json:"from" jsonschema:"first charge at this price"`
	Price     money.Money `json:"price"`
	Change    string      `json:"changePercent,omitempty" jsonschema:"change against the previous price, e.g. +14.3"`
	Scheduled bool        `json:"scheduled,omitempty" jsonschema:"a change announced in advance that has not arrived yet"`
}

PricePoint is one price a subscription has had (or will have).

type ReassignPaymentMethodInput

type ReassignPaymentMethodInput struct {
	FromID string `json:"fromId" jsonschema:"payment method the subscriptions use now (pm_…)"`
	ToID   string `json:"toId,omitempty" jsonschema:"payment method to move them to; empty leaves them without one"`
	DryRun bool   `json:"dryRun,omitempty"`
}

ReassignPaymentMethodInput moves every subscription from one payment method to another.

type ReassignResult

type ReassignResult struct {
	Moved   int    `json:"moved" jsonschema:"subscriptions that changed payment method"`
	Deleted string `json:"deleted,omitempty" jsonschema:"payment method that was deleted, if any"`
}

ReassignResult says how many subscriptions moved.

type ReminderEntry

type ReminderEntry struct {
	ID             string      `json:"id"`
	Kind           string      `json:"kind" jsonschema:"reminder (before a charge), decision (before the last day to cancel) or confirm (was it charged?)"`
	Status         string      `json:"status" jsonschema:"pending, sent, failed or cancelled" enum:"pending,sent,failed,cancelled"`
	DueAt          time.Time   `json:"dueAt" jsonschema:"when it is or was meant to go out"`
	SentAt         *time.Time  `json:"sentAt,omitempty"`
	Attempts       int         `json:"attempts"`
	Error          string      `json:"error,omitempty" jsonschema:"last delivery error"`
	SubscriptionID string      `json:"subscriptionId"`
	Subscription   string      `json:"subscription"`
	ChargeDate     engine.Date `json:"chargeDate"`
	To             string      `json:"to,omitempty" jsonschema:"member it is for"`
	Explanation    string      `json:"explanation" jsonschema:"what happened, in plain words"`
}

ReminderEntry is one reminder Exhale planned, sent or failed to send.

type ReminderLog

type ReminderLog struct {
	Entries []ReminderEntry `json:"entries"`
}

ReminderLog is the answer to "why didn't I get that reminder?".

type ReminderLogInput

type ReminderLogInput struct {
	Limit int `json:"limit,omitempty" query:"limit" jsonschema:"how many entries, newest first (default 100)" maximum:"500"`
}

ReminderLogInput selects how much of the log to read.

type RemoveLogoInput

type RemoveLogoInput struct {
	ID     string `json:"id" jsonschema:"subscription id (sub_…)"`
	DryRun bool   `json:"dryRun,omitempty"`
}

RemoveLogoInput names the subscription.

type RenameTagInput

type RenameTagInput struct {
	Tag    string `json:"tag" jsonschema:"the tag to rename (case does not matter)"`
	To     string `json:"to" jsonschema:"the new name; if a subscription already has it, the two merge"`
	DryRun bool   `json:"dryRun,omitempty"`
}

RenameTagInput renames a tag on every subscription that has it.

type SavePaymentMethodInput

type SavePaymentMethodInput struct {
	ID        string `json:"id,omitempty" jsonschema:"existing payment method id (pm_…) to update; empty to create"`
	Name      string `json:"name" jsonschema:"e.g. Revolut, Visa, PayPal"`
	Kind      string `json:"kind" enum:"card,bank,paypal,other"`
	Last4     string `json:"last4,omitempty" jsonschema:"last four digits" maxLength:"4"`
	ExpiresOn string `json:"expiresOn,omitempty" jsonschema:"last valid day, YYYY-MM-DD (for cards: last day of the expiry month)"`
	OwnerID   string `json:"ownerId,omitempty"`
	DryRun    bool   `json:"dryRun,omitempty"`
}

SavePaymentMethodInput creates (no id) or updates a payment method.

type SchedulePreview

type SchedulePreview struct {
	Dates []engine.Date `json:"dates"`
}

SchedulePreview lists the computed charge dates.

type Scope

type Scope string

Scope is what an actor may do (ADR 0018).

const (
	ScopeRead    Scope = "read"
	ScopeWrite   Scope = "write"
	ScopeTrusted Scope = "trusted"
)

type Service

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

Service implements the use cases.

func New

func New(st *store.Store, b *bus.Bus, log *slog.Logger, now func() time.Time) *Service

New returns a Service. now may be nil (time.Now).

func (*Service) ActorForMember

func (s *Service) ActorForMember(ctx context.Context, memberID string) (Actor, error)

ActorForMember returns the actor of a member (used by channel adapters).

func (*Service) AddDocument

func (s *Service) AddDocument(ctx context.Context, actor Actor, in AddDocumentInput) (DocumentResult, error)

AddDocument attaches a PDF or a picture to a subscription, or to one of its charges (an invoice or a receipt).

func (*Service) Audit

func (s *Service) Audit(ctx context.Context, actor Actor, _ struct{}) (AuditReport, error)

Audit reports duplicates, price increases, trials about to charge, cancellation deadlines, things about to expire, big charges coming, cards about to expire with what depends on them, unconfirmed charges and subscriptions marked as not used.

func (*Service) Bootstrap

func (s *Service) Bootstrap(ctx context.Context, in BootstrapInput) (Actor, error)

Bootstrap creates the household, its owner and default categories if no household exists yet. If one exists it returns its owner. It returns the owner as a system-usable actor.

func (*Service) BuildContext

func (s *Service) BuildContext(ctx context.Context, actor Actor, _ struct{}) (AgentContext, error)

BuildContext writes a live household briefing for AI agents: who lives here, what is subscribed, what is due, what needs attention, and the rules of the domain.

func (*Service) BulkUpdate

func (s *Service) BulkUpdate(ctx context.Context, actor Actor, in BulkUpdateInput) (BulkResult, error)

BulkUpdate changes the category, payment method or tags of several subscriptions, or pauses, resumes, cancels or deletes them, in one transaction: all of them or none.

func (*Service) Bus

func (s *Service) Bus() *bus.Bus

Bus returns the event bus.

func (*Service) Calendar

func (s *Service) Calendar(ctx context.Context, actor Actor, in CalendarInput) (CalendarView, error)

Calendar returns every dated event between two days: charges (real ones and, further ahead, the ones the schedule will produce), free trials ending, things expiring and last days to cancel.

func (*Service) Card

func (s *Service) Card(ctx context.Context, actor Actor, pmID string) (CardNotice, bool, error)

Card returns what a card-expiry message about pmID says to the actor. ok is false when it no longer applies: the card was renewed or deleted, or nothing uses it any more.

func (*Service) ChangeChargeAmount

func (s *Service) ChangeChargeAmount(ctx context.Context, actor Actor, in ChangeChargeAmountInput) (ChargeResult, error)

ChangeChargeAmount changes one charge's amount or the price from this charge on.

func (*Service) ChangeChargeDate

func (s *Service) ChangeChargeDate(ctx context.Context, actor Actor, in ChangeChargeDateInput) (ChargeResult, error)

ChangeChargeDate moves one charge ("this") or re-anchors the schedule from this charge on ("following").

func (*Service) CreateCategory

func (s *Service) CreateCategory(ctx context.Context, actor Actor, in CreateCategoryInput) (domain.Category, error)

CreateCategory adds a category.

func (*Service) CreateSubscription

func (s *Service) CreateSubscription(ctx context.Context, actor Actor, in CreateSubscriptionInput) (SubscriptionDetail, error)

CreateSubscription creates a subscription and its upcoming charges.

func (*Service) DecideRenewal

func (s *Service) DecideRenewal(ctx context.Context, actor Actor, in DecideRenewalInput) (ChargeResult, error)

DecideRenewal records that the member wants to keep a renewal (or takes that back), so the "keep or cancel?" reminders stop. To cancel instead, use SetSubscriptionStatus.

func (*Service) DeleteCategory

func (s *Service) DeleteCategory(ctx context.Context, actor Actor, in DeleteCategoryInput) (DeleteResult, error)

DeleteCategory removes a category; its subscriptions become uncategorized.

func (*Service) DeleteDocument

func (s *Service) DeleteDocument(ctx context.Context, actor Actor, in DeleteDocumentInput) (DocumentResult, error)

DeleteDocument removes a document. It is kept 30 days, so undo can bring it back, then purged.

func (*Service) DeletePaymentMethod

func (s *Service) DeletePaymentMethod(ctx context.Context, actor Actor, in DeletePaymentMethodInput) (ReassignResult, error)

DeletePaymentMethod deletes a payment method, moving its subscriptions to another one first when asked.

func (*Service) DeleteSubscription

func (s *Service) DeleteSubscription(ctx context.Context, actor Actor, in DeleteSubscriptionInput) (DeleteResult, error)

DeleteSubscription removes a subscription (it can be restored with Undo).

func (*Service) DeleteTag

func (s *Service) DeleteTag(ctx context.Context, actor Actor, in DeleteTagInput) (BulkResult, error)

DeleteTag removes a tag from every subscription the actor can see.

func (*Service) DismissDuplicate

func (s *Service) DismissDuplicate(ctx context.Context, actor Actor, in DismissDuplicateInput) (DismissResult, error)

DismissDuplicate records that paying a service more than once is on purpose: the group is no longer reported by audit and nobody is messaged about it again. A group that changes (a third subscription joins it) is a new group.

func (*Service) DocumentFile

func (s *Service) DocumentFile(ctx context.Context, actor Actor, id string) (Document, []byte, error)

DocumentFile returns a document and its content.

func (*Service) DueOutbox

func (s *Service) DueOutbox(ctx context.Context, limit int) ([]OutboxItem, error)

DueOutbox returns up to limit items due at or before now.

func (*Service) Duplicate

func (s *Service) Duplicate(ctx context.Context, actor Actor, key string) (DuplicateNotice, bool, error)

Duplicate returns the duplicate group with key as the actor sees it. ok is false when it no longer exists or was said to be fine.

func (*Service) Export

func (s *Service) Export(ctx context.Context, actor Actor, _ struct{}) (ExportData, error)

Export returns the household's data: reference data, every subscription the actor can see and all its charges.

func (*Service) FileData

func (s *Service) FileData(ctx context.Context, actor Actor, id string) ([]byte, error)

FileData returns the content of a live file (a document or a logo) of a subscription the actor can see, for the full export.

func (*Service) GetHousehold

func (s *Service) GetHousehold(ctx context.Context, actor Actor, _ struct{}) (HouseholdInfo, error)

GetHousehold returns the household reference data.

func (*Service) GetKeepAwake

func (s *Service) GetKeepAwake(ctx context.Context, _ Actor, _ struct{}) (KeepAwakeInfo, error)

GetKeepAwake returns the keep-awake setting and whether it is in effect.

func (*Service) GetSubscription

func (s *Service) GetSubscription(ctx context.Context, actor Actor, in GetSubscriptionInput) (SubscriptionDetail, error)

GetSubscription returns a subscription with upcoming and past charges.

func (*Service) Import

func (s *Service) Import(ctx context.Context, actor Actor, in ImportInput) (ImportResult, error)

Import creates many subscriptions in one step. Items that fail validation or duplicate an existing subscription are skipped and reported; the rest are created. With DryRun it is a review list: nothing is saved.

func (*Service) ListAudit

func (s *Service) ListAudit(ctx context.Context, _ Actor, in AuditLogInput) (AuditLog, error)

ListAudit returns recent changes made by members, agents and the system.

func (*Service) ListCharges

func (s *Service) ListCharges(ctx context.Context, actor Actor, in ListChargesInput) (ChargeList, error)

ListCharges returns charges due in a date range, e.g. the next 30 days.

func (*Service) ListDocuments

func (s *Service) ListDocuments(ctx context.Context, actor Actor, in ListDocumentsInput) (DocumentList, error)

ListDocuments returns the documents attached to a subscription the actor can see.

func (*Service) ListSubscriptions

func (s *Service) ListSubscriptions(ctx context.Context, actor Actor, in ListSubscriptionsInput) (SubscriptionList, error)

ListSubscriptions returns the subscriptions visible to the actor.

func (s *Service) Logo(ctx context.Context, actor Actor, subscriptionID string) (Document, []byte, error)

Logo returns the uploaded logo of a subscription the actor can see.

func (*Service) MarkDelivered

func (s *Service) MarkDelivered(ctx context.Context, id string) error

MarkDelivered records a successful delivery.

func (*Service) MarkFailed

func (s *Service) MarkFailed(ctx context.Context, item OutboxItem, cause error) error

MarkFailed records a failed attempt and schedules a retry with backoff.

func (*Service) MarkObsolete

func (s *Service) MarkObsolete(ctx context.Context, item OutboxItem) error

MarkObsolete records that an item was not sent because it no longer applies.

func (*Service) MarkUndeliverable

func (s *Service) MarkUndeliverable(ctx context.Context, item OutboxItem, cause error) error

MarkUndeliverable records that an item could not be sent to anyone and will not be retried: there is nowhere to send it.

func (*Service) MatchStatement

func (s *Service) MatchStatement(ctx context.Context, actor Actor, in MatchStatementInput) (StatementMatch, error)

MatchStatement compares the transactions of a statement with the charges Exhale expects in the same period. It changes nothing.

func (*Service) MonthBalances

func (s *Service) MonthBalances(ctx context.Context, actor Actor, in BalancesInput) (Balances, error)

MonthBalances computes who owes whom for the charges of one month of the subscriptions that have cost shares. The payer of a subscription is who actually paid; shares say who should carry it.

func (*Service) NeedsAttention

func (s *Service) NeedsAttention(ctx context.Context, actor Actor, _ struct{}) (AttentionList, error)

NeedsAttention lists unconfirmed past charges and expiring payment methods.

func (*Service) NextOutboxDue

func (s *Service) NextOutboxDue(ctx context.Context) (time.Time, bool)

NextOutboxDue returns when the next pending item is due, if any.

func (*Service) Now

func (s *Service) Now() time.Time

Now returns the service clock.

func (*Service) Owner

func (s *Service) Owner(ctx context.Context) (Actor, error)

Owner returns the first owner of the household as a trusted actor, or ErrNotFound if no household exists yet.

func (*Service) Plan

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

Plan materializes upcoming charges for every active subscription, enqueues their reminders and confirmation questions in the outbox, and auto-confirms charges of trusted subscriptions. It is idempotent: every outbox entry has a dedupe key, so running it again never duplicates a reminder (ADR 0003).

func (*Service) PreviewSchedule

func (s *Service) PreviewSchedule(_ context.Context, _ Actor, in PreviewScheduleInput) (SchedulePreview, error)

PreviewSchedule computes charge dates with the billing engine. The web calls it while a recurrence is being edited, so dates are never computed in two places (ADR 0013).

func (*Service) PurgeDeletedFiles

func (s *Service) PurgeDeletedFiles(ctx context.Context) (int64, error)

PurgeDeletedFiles removes for good the files deleted more than FileRetention ago. The scheduler calls it every hour.

func (*Service) ReassignPaymentMethod

func (s *Service) ReassignPaymentMethod(ctx context.Context, actor Actor, in ReassignPaymentMethodInput) (ReassignResult, error)

ReassignPaymentMethod moves all subscriptions paid with one method to another: the one-step answer to "this card expires next month".

func (*Service) Reminders

func (s *Service) Reminders(ctx context.Context, actor Actor, in ReminderLogInput) (ReminderLog, error)

Reminders returns the reminders of the household, newest first, each with what happened to it: sent, waiting, failed and why, or no longer needed.

func (s *Service) RemoveLogo(ctx context.Context, actor Actor, in RemoveLogoInput) (LogoResult, error)

RemoveLogo drops an uploaded logo: the subscription shows the bundled brand mark or its monogram again.

func (*Service) RenameTag

func (s *Service) RenameTag(ctx context.Context, actor Actor, in RenameTagInput) (BulkResult, error)

RenameTag renames a tag everywhere the actor can see it.

func (*Service) SavePaymentMethod

func (s *Service) SavePaymentMethod(ctx context.Context, actor Actor, in SavePaymentMethodInput) (domain.PaymentMethod, error)

SavePaymentMethod creates or updates a payment method.

func (*Service) SetChargeStatus

func (s *Service) SetChargeStatus(ctx context.Context, actor Actor, in SetChargeStatusInput) (ChargeResult, error)

SetChargeStatus marks a charge paid, failed, skipped, or reopens it.

func (*Service) SetKeepAwake

func (s *Service) SetKeepAwake(ctx context.Context, actor Actor, in SetKeepAwakeInput) (KeepAwakeInfo, error)

SetKeepAwake changes when the computer Exhale runs on is kept awake. Owners only.

func (s *Service) SetLogo(ctx context.Context, actor Actor, in SetLogoInput) (LogoResult, error)

SetLogo makes an uploaded picture the subscription's logo. The picture is decoded and drawn again as a PNG of at most 256×256: whatever else the file carried (metadata, a script, a second format hidden inside) is not kept. SVG is refused (ADR 0027).

func (*Service) SetSubscriptionStatus

func (s *Service) SetSubscriptionStatus(ctx context.Context, actor Actor, in SetSubscriptionStatusInput) (SubscriptionDetail, error)

SetSubscriptionStatus changes the lifecycle of a subscription. Cancelling at period end keeps the service until the day before the next charge.

func (*Service) Snooze

func (s *Service) Snooze(ctx context.Context, actor Actor, in SnoozeInput) (SnoozeResult, error)

Snooze brings a reminder, a "keep or cancel?" or a "was it charged?" back in a few days at the reminder time, never past the point where it is useless: a decision snoozed past the last day to cancel comes back on that day.

func (*Service) SpendBreakdown

func (s *Service) SpendBreakdown(ctx context.Context, actor Actor, period, by string, ids []string) (Breakdown, error)

SpendBreakdown computes a Breakdown of the active subscriptions the actor can see; ids, when given, keep only those subscriptions.

func (*Service) SpendHistory

func (s *Service) SpendHistory(ctx context.Context, actor Actor, in SpendingHistoryInput) (SpendingHistory, error)

SpendHistory returns what was really paid each month (from the charge ledger, not from current prices) and what is expected in the coming months.

func (*Service) Store

func (s *Service) Store() *store.Store

Store returns the underlying store (for adapters that need raw queries, such as message bookkeeping).

func (*Service) Summary

func (s *Service) Summary(ctx context.Context, actor Actor, _ struct{}) (SpendingSummary, error)

Summary returns recurring spend per month and year (per currency and converted into the main currency), per category and per payment method, plus the charges of the next 7 days.

func (*Service) Undo

func (s *Service) Undo(ctx context.Context, actor Actor, in UndoInput) (UndoResult, error)

Undo reverts a recorded change by restoring the entity's previous state (ADR 0018: every agent change is reversible).

func (*Service) UpdateCategory

func (s *Service) UpdateCategory(ctx context.Context, actor Actor, in UpdateCategoryInput) (domain.Category, error)

UpdateCategory renames or recolours a category.

func (*Service) UpdateHousehold

func (s *Service) UpdateHousehold(ctx context.Context, actor Actor, in UpdateHouseholdInput) (HouseholdInfo, error)

UpdateHousehold changes the display currency, time zone, language, name or default reminder time. Owners only.

func (*Service) UpdateSubscription

func (s *Service) UpdateSubscription(ctx context.Context, actor Actor, in UpdateSubscriptionInput) (SubscriptionDetail, error)

UpdateSubscription edits a subscription.

type SetChargeStatusInput

type SetChargeStatusInput struct {
	ChargeID      string `json:"chargeId"`
	Status        string `json:"status" jsonschema:"paid, failed, skipped, or pending to reopen" enum:"paid,failed,skipped,pending"`
	ChargedAmount string `json:"chargedAmount,omitempty" jsonschema:"what the bank actually took, decimal string (optional)"`
	ChargedIn     string `json:"chargedCurrency,omitempty" jsonschema:"currency of chargedAmount, e.g. EUR when a USD service is paid from a EUR card"`
	Note          string `json:"note,omitempty"`
	DryRun        bool   `json:"dryRun,omitempty"`
}

SetChargeStatusInput resolves a charge.

type SetKeepAwakeInput

type SetKeepAwakeInput struct {
	Mode          *string `` /* 163-byte string literal not displayed */
	From          *string `json:"from,omitempty" jsonschema:"start of the hours, HH:MM in the household time zone"`
	To            *string `` /* 126-byte string literal not displayed */
	KeepDisplayOn *bool   `json:"keepDisplayOn,omitempty" jsonschema:"also keep the display on; false lets it sleep while the system stays awake"`
	DryRun        bool    `json:"dryRun,omitempty"`
}

SetKeepAwakeInput changes when the computer is kept awake. Only the fields you pass change.

type SetLogoInput

type SetLogoInput struct {
	ID     string
	Data   []byte
	DryRun bool
}

SetLogoInput replaces a subscription's logo with an uploaded picture.

type SetSubscriptionStatusInput

type SetSubscriptionStatusInput struct {
	ID     string `json:"id"`
	Action string `` /* 128-byte string literal not displayed */
	DryRun bool   `json:"dryRun,omitempty"`
}

SetSubscriptionStatusInput pauses, resumes or cancels a subscription.

type SharedCharge

type SharedCharge struct {
	Subscription string      `json:"subscription"`
	Date         engine.Date `json:"date"`
	Amount       money.Money `json:"amount" jsonschema:"in the display currency"`
	PaidBy       string      `json:"paidBy"`
	Status       string      `json:"status"`
}

SharedCharge is one split charge of the month.

type SnoozeInput

type SnoozeInput struct {
	ChargeID string `json:"chargeId"`
	Kind     string `` /* 200-byte string literal not displayed */
	Days     int    `json:"days" jsonschema:"bring it back in this many days, at the reminder time (1: tomorrow)" minimum:"1" maximum:"30"`
	DryRun   bool   `json:"dryRun,omitempty"`
}

SnoozeInput asks for a reminder again later.

type SnoozeResult

type SnoozeResult struct {
	ChargeID string      `json:"chargeId"`
	Kind     string      `json:"kind"`
	On       engine.Date `json:"on" jsonschema:"the day it comes back"`
	At       time.Time   `json:"at" jsonschema:"when it comes back"`
	Clamped  bool        `` /* 130-byte string literal not displayed */
	Planned  bool        `json:"alreadyPlanned" jsonschema:"a message about this charge was already planned that day, so no second one is added"`
	DryRun   bool        `json:"dryRun,omitempty"`
}

SnoozeResult says when the message comes back.

type SpendingHistory

type SpendingHistory struct {
	MainCurrency   string              `json:"mainCurrency"`
	Months         []MonthSpend        `json:"months" jsonschema:"oldest first; past months show what was paid, future months what is expected"`
	BySubscription []SubscriptionSpend `json:"bySubscription" jsonschema:"lifetime paid per subscription, largest first"`
	TotalPaid      money.Money         `json:"totalPaid" jsonschema:"everything marked paid in the months shown"`
	HeaviestMonth  string              `json:"heaviestMonth,omitempty" jsonschema:"upcoming month with the most to pay, YYYY-MM"`
	Unconverted    []string            `json:"unconverted" jsonschema:"currencies without a known rate, left out"`
}

SpendingHistory is real spend per month and per subscription.

type SpendingHistoryInput

type SpendingHistoryInput struct {
	Months int `json:"months,omitempty" query:"months" jsonschema:"months to look back and ahead, default 12" minimum:"1" maximum:"60"`
}

SpendingHistoryInput selects how many months to look back and ahead.

type SpendingSummary

type SpendingSummary struct {
	MainCurrency string        `json:"mainCurrency"`
	Monthly      []money.Money `json:"monthlyByCurrency" jsonschema:"active subscriptions normalized per month, one entry per currency"`
	Yearly       []money.Money `json:"yearlyByCurrency"`
	// MonthlyTotal and YearlyTotal are in the main currency, converting other
	// currencies at the latest ECB reference rate (see RatesDate).
	MonthlyTotal money.Money       `json:"monthlyTotal"`
	YearlyTotal  money.Money       `json:"yearlyTotal"`
	Converted    bool              `json:"converted" jsonschema:"true when other currencies were converted into the main currency"`
	Unconverted  []string          `json:"unconverted" jsonschema:"currencies without a known rate, left out of the totals"`
	Rates        map[string]string `json:"rates" jsonschema:"units of the main currency per 1 unit of each currency"`
	RatesDate    string            `json:"ratesDate,omitempty" jsonschema:"ECB reference date of the rates used"`
	ByCategory   []CategorySpend   `json:"byCategory" jsonschema:"monthly spend per category, in the main currency"`
	ByMethod     []MethodSpend     `json:"byPaymentMethod" jsonschema:"monthly spend per payment method, in the main currency"`
	Active       int               `json:"activeSubscriptions"`
	NextCharges  []ChargeView      `json:"next7Days"`
	// SetAside is the monthly equivalent of everything that is not billed
	// monthly: what to put away each month so yearly bills do not surprise.
	SetAside money.Money `` /* 183-byte string literal not displayed */
	// BeforePayday is set when the household has a payday.
	BeforePayday *PaydayOutlook `json:"beforePayday,omitempty" jsonschema:"what is due before the next payday, when the household has one"`
	// Stopped is what cancelling saved: "you stopped paying X per year".
	Stopped StoppedSpend `` /* 249-byte string literal not displayed */
}

SpendingSummary totals recurring spend.

type StatementLine

type StatementLine struct {
	Date        string `json:"date" jsonschema:"when the money left, YYYY-MM-DD"`
	Description string `json:"description" jsonschema:"the text the bank shows, e.g. NETFLIX.COM 866-579-7172"`
	Amount      string `` /* 129-byte string literal not displayed */
	Currency    string `json:"currency,omitempty" jsonschema:"ISO 4217 code; default: the household's currency"`
}

StatementLine is one outgoing payment of a bank or card statement.

type StatementMatch

type StatementMatch struct {
	From    engine.Date   `json:"from"`
	To      engine.Date   `json:"to"`
	Matches []ChargeMatch `json:"matches" jsonschema:"expected charges found in the statement, by due date"`
	// Missing are open charges due in the period that no transaction
	// matches.
	Missing   []ChargeView `` /* 220-byte string literal not displayed */
	Unmatched int          `json:"unmatched" jsonschema:"transactions that match no expected charge"`
}

StatementMatch says which expected charges a statement confirms.

type StoppedSpend

type StoppedSpend struct {
	Yearly        money.Money           `json:"yearly" jsonschema:"what the cancelled subscriptions would cost per year at their last price, in the display currency"`
	Count         int                   `json:"count"`
	Subscriptions []StoppedSubscription `json:"subscriptions" jsonschema:"most recently ended first"`
}

StoppedSpend is what the household no longer pays because it cancelled subscriptions.

type StoppedSubscription

type StoppedSubscription struct {
	ID     string      `json:"id"`
	Name   string      `json:"name"`
	Yearly money.Money `json:"yearly" jsonschema:"per year at its last price, in the subscription currency"`
	// YearlyMain is Yearly converted into the display currency.
	YearlyMain money.Money `json:"yearlyMain" jsonschema:"per year, in the display currency"`
	Until      engine.Date `json:"until,omitzero" jsonschema:"last day of service"`
}

StoppedSubscription is one cancelled subscription and what it cost.

type SubscriptionDetail

type SubscriptionDetail struct {
	Subscription SubscriptionView `json:"subscription"`
	Upcoming     []ChargeView     `json:"upcoming"`
	History      []ChargeView     `json:"history"`
	PriceHistory []PricePoint     `json:"priceHistory" jsonschema:"every price it has had, oldest first, from the charge ledger; a scheduled change comes last"`
	Paid         PaidTotals       `json:"paid" jsonschema:"what it has really cost so far"`
}

SubscriptionDetail is a subscription with its upcoming and past charges.

type SubscriptionList

type SubscriptionList struct {
	Subscriptions []SubscriptionView `json:"subscriptions"`
}

SubscriptionList is a list of subscriptions.

type SubscriptionRef

type SubscriptionRef struct {
	ID      string      `json:"id"`
	Name    string      `json:"name"`
	Payer   string      `json:"payer,omitempty"`
	Monthly money.Money `json:"monthly" jsonschema:"in the subscription currency"`
}

SubscriptionRef names a subscription in a finding.

type SubscriptionSpend

type SubscriptionSpend struct {
	ID    string      `json:"id"`
	Name  string      `json:"name"`
	Paid  money.Money `json:"paid" jsonschema:"sum of paid charges, in the display currency"`
	Count int         `json:"count"`
	Since engine.Date `json:"since,omitzero"`
}

SubscriptionSpend is what one subscription has really cost.

type SubscriptionView

type SubscriptionView struct {
	domain.Subscription
	Cadence        string      `json:"cadence" jsonschema:"human description of the billing cycle, e.g. every 1 month"`
	NextCharge     engine.Date `json:"nextCharge,omitzero" jsonschema:"next charge date (YYYY-MM-DD), empty if none"`
	MonthlyCost    money.Money `json:"monthlyCost" jsonschema:"price normalized to one month, in the subscription currency"`
	CategoryName   string      `json:"categoryName,omitempty"`
	PaymentMethod  string      `json:"paymentMethod,omitempty" jsonschema:"payment method label, e.g. Revolut ••0937"`
	PayerName      string      `json:"payerName,omitempty"`
	CancelledUntil engine.Date `json:"serviceUntil,omitzero" jsonschema:"for cancelled subscriptions: last day of service"`
	YearlyCost     money.Money `json:"yearlyCost" jsonschema:"price normalized to one year, in the subscription currency"`
	InTrial        bool        `json:"inTrial" jsonschema:"the free trial is still running; the next charge is the first paid one"`
	CancelBy       engine.Date `` /* 155-byte string literal not displayed */
	Expires        bool        `json:"expires" jsonschema:"manual renewal: the next charge date is when it expires unless renewed"`
	AskUsage       bool        `` /* 127-byte string literal not displayed */
	// PlanSwitch is set only when the member recorded another price (altPrice).
	PlanSwitch *PlanSwitch `` /* 146-byte string literal not displayed */
	// Billing rules (weekday of the month, weekend shift).
	BillingDay    string      `` /* 206-byte string literal not displayed */
	NextScheduled engine.Date `` /* 156-byte string literal not displayed */
}

SubscriptionView is a subscription plus everything a reader (human or AI agent) needs to understand it without extra lookups.

type TagCount

type TagCount struct {
	Name          string `json:"name"`
	Subscriptions int    `json:"subscriptions"`
}

TagCount is a tag with how many subscriptions carry it.

type TermsInput

type TermsInput struct {
	StartedOn   string         `json:"startedOn,omitempty" jsonschema:"when the member first subscribed, YYYY-MM-DD. History only: it creates no charges"`
	TrialEndsOn string         `` /* 146-byte string literal not displayed */
	NoticeDays  int            `` /* 176-byte string literal not displayed */
	Renewal     string         `` /* 189-byte string literal not displayed */
	CancelURL   string         `json:"cancelUrl,omitempty" jsonschema:"page where it is cancelled or managed, https://…"`
	Account     string         `json:"account,omitempty" jsonschema:"login or email the subscription lives under"`
	Shares      []domain.Share `json:"shares,omitempty" jsonschema:"how members split the cost, by relative weight; empty: the payer carries it all"`
}

TermsInput are the contract facts that can be set when creating a subscription.

type Transfer

type Transfer struct {
	From   string      `json:"from" jsonschema:"member id who pays"`
	To     string      `json:"to" jsonschema:"member id who receives"`
	Amount money.Money `json:"amount"`
	Text   string      `json:"text" jsonschema:"e.g. Ana pays Alex 12.50 EUR"`
}

Transfer settles part of the month between two members.

type UndoInput

type UndoInput struct {
	AuditID string `json:"auditId,omitempty" jsonschema:"id returned by a write (aud_…)"`
	// AuditIDs revert several changes together, e.g. every change of a bulk
	// edit: all of them or none.
	AuditIDs []string `` /* 134-byte string literal not displayed */
	DryRun   bool     `json:"dryRun,omitempty"`
}

UndoInput identifies the change to revert.

type UndoResult

type UndoResult struct {
	Restored string `json:"restored" jsonschema:"entity id that was restored"`
	AuditID  string `json:"auditId"`
	// Undone counts the changes reverted (more than one with auditIds).
	Undone int `json:"undone"`
}

UndoResult reports what was restored.

type UnusedSubscription

type UnusedSubscription struct {
	Subscription SubscriptionRef `json:"subscription"`
	Yearly       money.Money     `json:"yearly" jsonschema:"what it costs per year, in the subscription currency"`
	AnsweredOn   engine.Date     `json:"answeredOn"`
}

UnusedSubscription is one the member said they no longer use.

type UpdateCategoryInput

type UpdateCategoryInput struct {
	ID     string  `json:"id" jsonschema:"category id (cat_…)"`
	Name   *string `json:"name,omitempty"`
	Color  *string `json:"color,omitempty" jsonschema:"hex color, e.g. #7c6cff"`
	DryRun bool    `json:"dryRun,omitempty"`
}

UpdateCategoryInput renames or recolours a category.

type UpdateHouseholdInput

type UpdateHouseholdInput struct {
	Name         *string `json:"name,omitempty" maxLength:"60"`
	Currency     *string `json:"currency,omitempty" jsonschema:"display currency (ISO 4217): totals are converted into it"`
	Timezone     *string `json:"timezone,omitempty" jsonschema:"IANA time zone, e.g. Europe/Madrid"`
	Locale       *string `` /* 140-byte string literal not displayed */
	ReminderTime *string `` /* 141-byte string literal not displayed */
	Payday       *int    `` /* 186-byte string literal not displayed */
	// Repeat until handled: how often an unanswered "was it charged?" comes back.
	ConfirmTimes     *int `` /* 221-byte string literal not displayed */
	ConfirmEveryDays *int `json:"confirmEveryDays,omitempty" jsonschema:"days between two of those questions" minimum:"1" maximum:"30"`
	DryRun           bool `json:"dryRun,omitempty"`
}

UpdateHouseholdInput changes household preferences. Only the fields you pass change.

type UpdateSubscriptionInput

type UpdateSubscriptionInput struct {
	ID              string  `json:"id" jsonschema:"subscription id (sub_…)"`
	Name            *string `json:"name,omitempty"`
	Price           *string `json:"price,omitempty" jsonschema:"new price from the next open charge on, decimal string"`
	CategoryID      *string `json:"categoryId,omitempty"`
	PaymentMethodID *string `json:"paymentMethodId,omitempty" jsonschema:"applies to this and following charges"`
	PayerID         *string `json:"payerId,omitempty"`
	Private         *bool   `json:"private,omitempty"`
	URL             *string `json:"url,omitempty"`
	Notes           *string `json:"notes,omitempty"`
	AutoConfirm     *bool   `json:"autoConfirm,omitempty"`
	ReminderDays    []int   `json:"reminderDays,omitempty"`
	ReminderHour    *int    `json:"reminderHour,omitempty" jsonschema:"local hour of day for reminders (0-23)"`
	Interval        *int    `json:"interval,omitempty" jsonschema:"new number of units between charges, from the next charge on" minimum:"1"`
	Unit            *string `json:"unit,omitempty" jsonschema:"new billing unit, from the next charge on" enum:"day,week,month,year"`
	NextCharge      *string `` /* 162-byte string literal not displayed */
	DayRule         *string `` /* 470-byte string literal not displayed */
	WeekendShift    *string `` /* 343-byte string literal not displayed */
	EndDate         *string `` /* 126-byte string literal not displayed */
	PriceFrom       *string `` /* 170-byte string literal not displayed */
	StartedOn       *string `json:"startedOn,omitempty" jsonschema:"when the member first subscribed, YYYY-MM-DD; empty string clears it"`
	TrialEndsOn     *string `` /* 129-byte string literal not displayed */
	NoticeDays      *int    `json:"noticeDays,omitempty" jsonschema:"days of notice required to cancel before a renewal; 0 removes it"`
	Renewal         *string `json:"renewal,omitempty" jsonschema:"auto or manual (expires unless renewed)" enum:"auto,manual"`
	CancelURL       *string `json:"cancelUrl,omitempty" jsonschema:"page where it is cancelled or managed"`
	Account         *string `json:"account,omitempty" jsonschema:"login or email the subscription lives under"`
	// Shares replace the cost split; an empty list gives the cost back to the payer.
	Shares []domain.Share `json:"shares,omitempty" jsonschema:"how members split the cost, by relative weight"`
	Using  *bool          `json:"using,omitempty" jsonschema:"the member's answer to 'still using this?'"`
	ExtrasUpdate
	DryRun bool `json:"dryRun,omitempty"`
}

UpdateSubscriptionInput changes subscription-level fields. Only non-nil fields change. Price changes made here apply from the next open charge on ("this and following"); use ChangeChargeAmount for a single charge.

Jump to

Keyboard shortcuts

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