e2esim

package
v0.5.2 Latest Latest
Warning

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

Go to latest
Published: Sep 5, 2026 License: Apache-2.0 Imports: 32 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func RevenueCrossCheck

func RevenueCrossCheck(
	ctx context.Context,
	reportsRepo data.ReportsQuerier,
	balances []model.AccountBalance,
	companyID uuid.UUID,
	from, to string,
) error

RevenueCrossCheck compares ledger-recorded net revenue against operationally-recorded net revenue computed independently from the raw orders/order_returns tables via data.ReportsQuerier.SalesTotals. These two code paths share no logic, so agreement is a real correctness signal, not a tautology.

"Net" here means post-discount, post-return, matching model.SalesSummary.NetRevenue exactly: accounting.Service.postSale (accounting/service.go) always credits 4000 Sales Revenue at gross and separately debits 4100 Sales Discounts for any discount — so the ledger's net revenue is (4000 credit total) - (4000 debit total, from OrderReturned's direct reversal) - (4100 debit total). Comparing against SalesTotals.TotalRevenue (which excludes returns) would be wrong once a persona uses returns; NetRevenue is the correct counterpart.

Types

type ArchetypeKind

type ArchetypeKind int

ArchetypeKind distinguishes retail (Simple products only) from F&B (Recipe products + Ingredient stock, exercising the recipe-cost/COGS path). Round 1 only implements Retail personas.

const (
	ArchetypeRetail ArchetypeKind = iota
	ArchetypeFnB
)

type AssetSpec

type AssetSpec struct {
	Name             string
	Category         string
	Cost             int64 // subunits
	Salvage          int64 // subunits
	UsefulLifeMonths int64
}

AssetSpec describes one fixed asset a persona registers at the start of its history.

type BalanceSheetResult

type BalanceSheetResult struct {
	Assets      int64
	Liabilities int64
	Equity      int64
}

BalanceSheetResult sums lifetime account balances by model.AccountType. Written fresh here rather than importing view/accounting/helpers.go's RollUp, which is presentation layer and shouldn't be a harness dependency.

func BalanceSheet

func BalanceSheet(balances []model.AccountBalance) BalanceSheetResult

BalanceSheet classifies balances (as returned by TrialBalance) by account type. Asset accounts are debit-normal (balance = debit - credit); liability and equity accounts are credit-normal (balance = credit - debit).

func (BalanceSheetResult) Balanced

func (r BalanceSheetResult) Balanced() bool

Balanced reports the fundamental accounting equation: Assets == Liabilities + Equity.

type BranchSpec

type BranchSpec struct {
	Name     string
	Address  string
	Stations []string
	// Weight controls this branch's share of daily order traffic relative
	// to the persona's other branches (largest-remainder split, ported from
	// internal/seeddata/history.go's distributeOrdersAcrossStations). Zero
	// defaults to 1 — a single-branch persona can leave this unset.
	Weight float64
}

BranchSpec describes one branch and the stations inside it.

type Client

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

Client drives the web app the way a real browser would: it persists cookies across requests (auth + CSRF), follows redirects, and attaches the CSRF token and _method override transparently so callers never touch either. Each Client is one logged-in actor — use Harness.NewClient for a second staff member acting concurrently in the same simulated business.

func NewClient

func NewClient(e *echo.Echo) (*Client, error)

NewClient wraps e in a Client with its own cookie jar (its own session).

func (*Client) DeleteForm

func (c *Client) DeleteForm(path string, values url.Values) (*http.Response, error)

DeleteForm is PutForm's DELETE counterpart.

func (*Client) Get

func (c *Client) Get(path string) (*http.Response, error)

Get issues a GET request, following redirects. There's no per-call cancellation to propagate here: the transport is in-process (inprocessTransport.RoundTrip calls echo.ServeHTTP directly, no real socket), and every real unit of work underneath it (SQLite queries) is already context-aware at the repo layer.

func (*Client) Login

func (c *Client) Login(email, password string) error

Login submits POST /login. See Register's doc comment for why the auth cookie, not the response status/body, is the success signal.

func (*Client) PostForm

func (c *Client) PostForm(path string, values url.Values) (*http.Response, error)

PostForm submits values as application/x-www-form-urlencoded, attaching the CSRF token automatically. Redirects are followed by the underlying http.Client (converting POST to GET on 301/302/303, per net/http's default policy), so the returned response is the final page in the chain.

func (*Client) PutForm

func (c *Client) PutForm(path string, values url.Values) (*http.Response, error)

PutForm simulates a PUT via the app's _method override (core/app.go's MethodOverrideWithConfig) — real HTML forms can't submit PUT/DELETE, so every "edit" route in this app is actually POST with _method=PUT.

func (*Client) Register

func (c *Client) Register(name, email, password string) error

Register submits POST /register. Returns an error if the app didn't set the auth cookie (webhandler.AuthCookieName) — the reliable signal of success, independent of which page the post-register redirect chain lands on (varies: /setup/companies/new for a fresh owner, / otherwise).

type DemoStats

type DemoStats struct {
	Businesses int
	Orders     int
	Sessions   int
}

DemoStats summarizes what SeedDemo generated.

func SeedDemo

func SeedDemo(
	ctx context.Context,
	h *Harness,
	out io.Writer,
	seed int64,
	scale float64,
) (DemoStats, error)

SeedDemo populates h with the two public demo businesses documented in README.md — Toko Makmur Jaya (retail) and Kopi Selasar (coffee shop) — both owned by the same person, matching the real demo exactly. seed controls RNG reproducibility (offset by 1 for the second business, so a single --seed flag still determines the whole run); scale multiplies both businesses' daily order volume on top of their own baseline (see TokoMakmurJaya/KopiSelasar's doc comments for why that baseline was lowered from the old seeder's literal 100/day).

After both businesses are generated, SeedDemo verifies their books balance (TrialBalance, BalanceSheet, RevenueCrossCheck) before returning successfully — the seeded demo image is a real correctness check on the accounting engine, not just a data-population step.

func (DemoStats) String

func (s DemoStats) String() string

type DiscountSpec

type DiscountSpec struct {
	Name  string
	Type  string
	Value int64
}

DiscountSpec describes one discount a persona creates via POST /discounts, matching webhandler/discount_handler.go's createDiscount validation: Type must be "fixed" or "percentage". Value is subunits for "fixed" (converted via moneyForm) or a plain percentage integer (1-100) for "percentage".

type ExpenseProfile

type ExpenseProfile struct {
	RentPerBranch      int64 // subunits, posted on day-of-month 1
	UtilitiesPerBranch int64 // subunits, posted on day-of-month 5
	PayrollBase        int64 // subunits, company-wide, posted on day-of-month 25
	MiscChance         float64
	MiscAmount         int64 // subunits
}

ExpenseProfile describes a persona's recurring costs, ported from internal/seeddata/history.go's writeRecurringExpenses shape (fixed recurring entries on specific days-of-month, plus a Bernoulli-triggered irregular one).

type Harness

type Harness struct {
	Echo   *echo.Echo
	Clock  *clock.Fake
	DB     *sql.DB
	Client *Client // the owner's client — call Register/Login on it first
	// contains filtered or unexported fields
}

Harness wires a full, real okpos app (real Echo router, real middleware — CSRF, auth-cookie parsing, role checks — real SQLite, real accounting engine) against an in-memory database, with two swaps that make it suitable for fast, deterministic simulation instead of a live server: a clock.Fake in place of the system clock, and a SyncEnqueuer in place of the async job queue (see enqueuer.go's doc comment for why).

func NewHarness

func NewHarness(ctx context.Context, t0 time.Time, opts ...HarnessOption) (*Harness, error)

NewHarness builds a fresh app pinned to t0. Advance h.Clock to move the simulated "now" forward. By default it uses a throwaway in-memory database and a discarding logger — the right shape for tests and for RunPersona's own use; pass WithDBPath/WithLogger for a real seeding run (see cmd/seed.go) that needs to write to and log against a real database.

func (*Harness) AccountingRepo

func (h *Harness) AccountingRepo() data.AccountingRepoer

AccountingRepo exposes the accounting repo for verification checks.

func (*Harness) BranchesByCompany

func (h *Harness) BranchesByCompany(
	ctx context.Context,
	companyID uuid.UUID,
) ([]model.Branch, error)

BranchesByCompany returns every branch for companyID.

func (*Harness) Close

func (h *Harness) Close() error

Close releases the harness's database.

func (*Harness) CompanyByOwnerEmail

func (h *Harness) CompanyByOwnerEmail(
	ctx context.Context,
	ownerEmail string,
) (*model.Company, error)

CompanyByOwnerEmail finds the (single, in Round 1) company owned by the user with the given email — used right after Register+create-company to discover the handle/ID needed for every subsequent request.

func (*Harness) CompanyByOwnerEmailAndName

func (h *Harness) CompanyByOwnerEmailAndName(
	ctx context.Context,
	ownerEmail, name string,
) (*model.Company, error)

CompanyByOwnerEmailAndName finds one of an owner's companies by exact name — needed once an owner has more than one company (see Persona's SharedOwner field), where CompanyByOwnerEmail's "just take the first one" is ambiguous. data.CompanyRepoer.ListByOwner is itself documented as existing because "a user may own multiple companies (pro)".

func (*Harness) NewClient

func (h *Harness) NewClient() (*Client, error)

NewClient returns a second Client (its own cookie jar) against the same running app — used for staff actors, who must log in separately to get correctly company-scoped JWT claims (see webhandler/middleware.go's CompanyMiddleware: an owner is checked via company.OwnerID == claims.UserID, but staff are checked via claims.CompanyID == company.ID, populated at login time — the owner's own client can't act as staff).

func (*Harness) OpenOrderBySession

func (h *Harness) OpenOrderBySession(
	ctx context.Context,
	sessionID uuid.UUID,
) (*model.Order, error)

OpenOrderBySession returns the still-open order for sessionID — used to read back a real, server-computed total after applying a discount, since percentage-discount rounding is the server's business, not something a caller should recompute independently.

func (*Harness) OrderItemsByOrder

func (h *Harness) OrderItemsByOrder(
	ctx context.Context,
	orderID uuid.UUID,
) ([]model.OrderItem, error)

OrderItemsByOrder returns every line item on orderID.

func (*Harness) ProductBySKU

func (h *Harness) ProductBySKU(
	ctx context.Context,
	companyID uuid.UUID,
	sku string,
) (*model.Product, error)

ProductBySKU looks up a product the runner itself created, by the deterministic SKU it assigned — the same lookup production's barcode-scan flow uses (data.ProductRepoer.GetBySKU).

func (*Harness) ReportsRepo

func (h *Harness) ReportsRepo() data.ReportsQuerier

ReportsRepo exposes the reports repo for verification cross-checks.

func (*Harness) SelectableDiscounts

func (h *Harness) SelectableDiscounts(
	ctx context.Context,
	companyID uuid.UUID,
	at time.Time,
) ([]model.Discount, error)

SelectableDiscounts returns companyID's active, uncoded, currently-valid discounts as of at — the same query the real POS discount dropdown uses (data.DiscountRepoer.ListSelectable) — used by the runner to resolve a just-created DiscountSpec's name to its server-assigned ID.

func (*Harness) StationsByCompany

func (h *Harness) StationsByCompany(
	ctx context.Context,
	companyID uuid.UUID,
) ([]model.POSStation, error)

StationsByCompany returns every POS station for companyID.

type HarnessOption

type HarnessOption func(*harnessSettings)

HarnessOption configures NewHarness for callers beyond the default in-memory test setup — currently just production/CLI seeding (cmd/seed.go), which needs a real file-backed database and a real logger instead of the fast, throwaway defaults every e2e test wants.

func WithDBPath

func WithDBPath(path string) HarnessOption

WithDBPath points the harness at a real file-backed SQLite database (via repo.NewSQLiteDB, which configures WAL + busy_timeout + foreign_keys for a file path) instead of the default unique shared-cache in-memory database. Leave unset for tests.

func WithLogger

func WithLogger(l *slog.Logger) HarnessOption

WithLogger routes the harness's internal logging (and, via slog.SetDefault, webhandler's package-level error logging — see the slog.SetDefault call below) through l instead of discarding it. Leave unset for tests.

type Persona

type Persona struct {
	Name      string // used as the company name; handle is auto-slugified
	Archetype ArchetypeKind
	Currency  string

	OwnerName     string
	OwnerEmail    string
	OwnerPassword string

	// StartOffset/Duration place the persona's history on the timeline
	// ending at the Harness's current clock value ("now"): history starts
	// StartOffset before now and runs for Duration.
	StartOffset time.Duration
	Duration    time.Duration

	Branches []BranchSpec
	Staff    []Staff // beyond the owner; nil is valid (solo owner)
	Products []ProductSpec

	AvgOrdersPerDayStart     float64
	Trajectory               TrajectoryFn
	ProcurementCycleDays     int
	ExpenseProfile           ExpenseProfile
	Discounts                []DiscountSpec // created via POST /discounts before the day loop
	DiscountRate             float64        // fraction of orders that get a random discount from Discounts
	EnableAssetTracking      bool
	EnableAdvancedAccounting bool
	FixedAssets              []AssetSpec

	// SharedOwner, if set, is an already-registered-and-authenticated owner
	// Client to reuse instead of registering a fresh one — lets a second
	// persona create a second company under the same real-world owner (e.g.
	// one person running two businesses), matching data.CompanyRepoer's
	// documented "a user may own multiple companies" support. Nil (default)
	// preserves today's behavior: RunPersona registers a brand-new owner.
	SharedOwner *Client

	// Scale multiplies AvgOrdersPerDayStart, mirroring
	// internal/seeddata.Config.Scale — the volume knob that keeps a
	// multi-month/year persona's e2e run fast.
	Scale float64

	// RNGSeed seeds the persona's own deterministic RNG (order timing, item
	// selection, noise) — same seed always produces the same simulated
	// history.
	RNGSeed int64
}

Persona declaratively describes a fictional business: its structure (branches, staff, catalog), its financial behavior (expenses, procurement, discounts), and the shape of its history over time (Trajectory). RunPersona drives every field below entirely through real HTTP requests against a Harness.

func BankruptBusiness

func BankruptBusiness() Persona

BankruptBusiness proves the ledger stays internally consistent even when the business it describes is failing: revenue plateaus for the first 40% of the year then collapses toward near-zero (CliffTrajectory), while rent and utilities stay FIXED — expenses don't decline just because revenue did, which is exactly what drives a real business into the ground. The accounting invariants (trial balance, balance sheet) don't require a healthy business, only a correctly-recorded one; this persona is the proof that a collapsing cash position (even a negative one, since nothing stops the ledger's Cash account from going negative — there's no "insufficient funds" check on recording an expense) still balances.

func BoomingBusiness

func BoomingBusiness() Persona

BoomingBusiness is the opposite of BankruptBusiness: explosive growth over a short window (9 months) rather than a slow climb — two branches from day one (rapid expansion, not one branch growing in place) and a team hired up front to handle the scale-up, with volume growing more than 10x from start to end.

func FnBCafe

func FnBCafe() Persona

FnBCafe proves the recipe/ingredient path end-to-end: a solo-owner coffee shop where every drink sold deducts ingredient stock (not its own stock — recipe products have none) and posts COGS computed from the recipe's components, not a flat per-product cost. Single branch/station and no staff, deliberately, so this persona isolates the recipe dimension from the multi-branch/multi-staff one (see MultiChain).

func GrowingSmallShopWithStaff

func GrowingSmallShopWithStaff() Persona

GrowingSmallShopWithStaff proves three mechanisms SoloRetailShop can't: multi-actor login/claims (a cashier rings up orders under their own session, not the owner's), depreciation idempotency (one fixed asset, run-depreciation invoked monthly across a 6-month history), and discount creation/application (Persona.DiscountRate/Discounts, exercised end to end for the first time anywhere in this suite — see runner.go's createDiscounts/simulateOrder). Real upward Trajectory — first persona to actually tell a growth story.

func KopiSelasar

func KopiSelasar() Persona

KopiSelasar ports the "Kopi Selasar" demo coffee-shop business from internal/seeddata/setup.go+catalog.go+assets.go verbatim — including all 8 recipes (the docs site says "six," but the code (ground truth) has always defined 8; this port follows the code). SharedOwner is left unset here — SeedDemo sets it to the already-registered Toko Makmur Jaya owner before running this persona, since both demo businesses belong to the same real-world owner.

func MultiChain

func MultiChain() Persona

MultiChain proves branch-weighted traffic distribution and multi-staff station rotation: three branches with different traffic shares, several staff across all three non-owner roles (including one "inventory" role staffer, who must be excluded from POS station rotation entirely — see runner.go's assignStationActors). Retail archetype, kept separate from FnBCafe so this persona isolates the multi-branch/multi-staff dimension.

func SoloRetailShop

func SoloRetailShop() Persona

SoloRetailShop is Round 1's minimum viable proof persona: a ~2.5-month-old single-branch, single-station retail shop with no staff at all — the owner does everything. Retail archetype (no recipes), gentle flat-ish trajectory, no fixed assets — keeps the first proof free of the multi-actor and depreciation dimensions covered by GrowingSmallShopWithStaff.

func SteadyGrowthBusiness

func SteadyGrowthBusiness() Persona

SteadyGrowthBusiness grows consistently month over month for a year and a half — LinearGrowthTrajectory more than doubles daily volume from start to end, smoothly rather than via a dramatic event, and a cashier is hired to help handle the growing load.

func ThreeYearVeteran

func ThreeYearVeteran() Persona

ThreeYearVeteran is a mature, long-running business: three years of history, a brief early ramp then a long flat plateau (an established customer base, not a startup), one staff member, and one fixed asset with a long useful life — long enough that run-depreciation has to catch up many periods across the full three-year span, not just one or two.

func TokoMakmurJaya

func TokoMakmurJaya() Persona

TokoMakmurJaya ports the "Toko Makmur Jaya" demo retail business from internal/seeddata/setup.go+catalog.go+assets.go verbatim (names, logins, catalog, discounts, fixed asset) — everything documented in README.md and the docs site. AvgOrdersPerDayStart is deliberately lower than the old seeder's literal 100/day: at that volume, this engine's real HTTP+ accounting round-trips per order would take 20-30+ minutes for 2 years of history, vs. the old raw-SQL seeder's few seconds. ~18/day keeps --scale 1.0 (demo.Dockerfile's default) in the low single-digit minutes while still producing a substantial multi-year history.

type ProductSpec

type ProductSpec struct {
	Name             string
	SKU              string
	Type             model.ProductType
	BasePrice        int64 // subunits; must be 0 for Ingredient
	Unit             string
	InitialQuantity  float64
	InitialUnitCost  int64   // subunits
	Weight           float64 // selection weight among sellable products
	Category         string  // optional; sent as the "categories" form field
	RecipeComponents []RecipeComponentSpec
}

ProductSpec describes one product in a persona's catalog. SKU is persona-assigned and deterministic so the runner can add it to an order via POST /pos/session/:id/scan (sku=...), exactly like a real barcode scanner, without any ID lookup.

type RecipeComponentSpec

type RecipeComponentSpec struct {
	IngredientSKU string
	Quantity      float64
}

RecipeComponentSpec references an ingredient by the SKU another ProductSpec in the same Persona was given.

type Result

type Result struct {
	Sessions         int
	Orders           int
	OrderItems       int
	Payments         int
	Expenses         int
	ProcurementRuns  int
	DepreciationRuns int
}

Result summarizes what RunPersona did — used both as a coarse sanity check ("Orders > 0") and, later, as the input to per-persona narrative assertions.

func RunPersona

func RunPersona(ctx context.Context, h *Harness, p Persona) (*Result, error)

RunPersona drives h through p's entire lifecycle — registration, company setup, branches/stations/staff/catalog, then a day-by-day simulated history — entirely via real HTTP requests against h.Echo. Every journal entry that results comes from accounting.Service.Handle, triggered by the real event-emission path (see enqueuer.go), not from any duplicated posting logic in this package. p is taken by value (not *Persona) deliberately: every persona builder (SoloRetailShop, TokoMakmurJaya, etc.) returns a Persona by value, and RunPersona is called exactly once per simulated business, not in a hot loop, so the copy is a one-time cost, not a perf concern.

type Staff

type Staff struct {
	Name     string
	Email    string
	Password string
	Role     string
}

Staff is one non-owner actor in a persona (beyond the owner, who always exists). Role must be "manager", "cashier", or "inventory" — the only roles POST /settings/users accepts (owner accounts are never company-scoped).

type SyncEnqueuer

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

SyncEnqueuer is a data.Enqueuer that processes accounting jobs synchronously, in the same call, instead of production's SQLite-backed queue drained by a background goroutine polling every 500ms (repo.NewGoqiteEnqueuer + worker.NewRunner). A simulation needs to see ledger state change the instant an HTTP request returns — sleeping past production's poll interval would be slow and still nondeterministic.

This swaps only the transport: core/dispatcher.go's event-name-to-job mapping is untouched, and worker.JobAccounting jobs are handled by calling accounting.Service.Handle directly — the exact same production code path, just invoked inline instead of via goqite+JSON. The payload still gets a JSON marshal/unmarshal round trip before Handle sees it (see normalizeJobPayload) — skipping that round trip was tried first and is NOT safe: domain services put typed Go values (uuid.UUID, int64) straight into event.Payload, and accounting.Service's payload-parsing helpers expect the JSON-native shapes (string, float64) those values become after a real marshal/unmarshal — e.g. a raw uuid.UUID in the map fails a map[string]any type assertion to string and gets silently treated as "invalid company_id". Round-tripping through JSON costs microseconds and guarantees this enqueuer matches production byte-for-byte.

func NewSyncEnqueuer

func NewSyncEnqueuer(svc *accounting.Service, log *slog.Logger) *SyncEnqueuer

NewSyncEnqueuer returns a SyncEnqueuer that posts accounting jobs via svc.

func (*SyncEnqueuer) Enqueue

func (e *SyncEnqueuer) Enqueue(ctx context.Context, job data.BackgroundJob) error

Enqueue implements data.Enqueuer.

type TrajectoryFn

type TrajectoryFn func(dayIndex, totalDays int) float64

TrajectoryFn returns a multiplier (typically centered around 1.0) for a given zero-based day index into the persona's timeline. It generalizes internal/seeddata/history.go's hardcoded `ramp := min(1, i/60.0)` (monotonic ramp-up only) into a pluggable per-persona curve — this is the seam Round 2's growth/decline/plateau/boom/bankruptcy personas plug into without any engine changes. The runner multiplies this into the existing weekday/noise shaping, so a TrajectoryFn only needs to describe the overall trend.

func CliffTrajectory

func CliffTrajectory(plateauMultiplier, cliffFraction, floorMultiplier float64) TrajectoryFn

CliffTrajectory holds steady at plateauMultiplier until cliffFraction of the timeline has elapsed, then declines linearly to floorMultiplier by the end — models a business that looks healthy and then collapses, rather than one that was always declining (LinearGrowthTrajectory with a high start and low end gives a steady decline instead; use that for a "slow bleed" persona and this one for a "sudden bankruptcy" persona).

func FlatTrajectory

func FlatTrajectory(rampDays int) TrajectoryFn

FlatTrajectory ramps gently up over rampDays then holds steady — the default for a persona that isn't trying to tell a growth/decline story.

func LinearGrowthTrajectory

func LinearGrowthTrajectory(startMultiplier, endMultiplier float64) TrajectoryFn

LinearGrowthTrajectory ramps from startMultiplier to endMultiplier evenly across the whole timeline. Passing a startMultiplier greater than endMultiplier produces a steady decline rather than growth.

type TrialBalanceResult

type TrialBalanceResult struct {
	Balances    []model.AccountBalance
	TotalDebit  int64
	TotalCredit int64
}

TrialBalanceResult holds the global "books balance" check: no such check exists anywhere else in the codebase today — AccountingRepoer.BalanceByAccount (repo/accounting_repo.go) returns per-account rows only.

func TrialBalance

func TrialBalance(
	ctx context.Context,
	accountingRepo data.AccountingRepoer,
	companyID uuid.UUID,
) (*TrialBalanceResult, error)

TrialBalance sums every account's lifetime debit/credit totals for companyID.

func (*TrialBalanceResult) Balanced

func (r *TrialBalanceResult) Balanced() bool

Balanced reports whether every debit is matched by a credit, company-wide.

Jump to

Keyboard shortcuts

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