Repository navigation
v0.2.0
This release upgrades cleanly from 0.1.6. Every new setting is additive and
every one of them defaults to absent, so a deployment that changes nothing sells
nothing, limits nobody, shows no advertising, and opens no connection it did not
open before. No route, tool or CSV column is removed. The one change to
schedule is in the aws Pulumi program, whose next pulumi up turns proxy
protocol on in front of ingress-nginx and interrupts the site for seconds to
about a minute while it does.
Added
An ha deployment profile: PostgreSQL sharded with Citus, run by Patroni, in
two shapes. deploy/helm/ now provisions the database as well as the
application — a StatefulSet per Citus group, the coordinator and its workers,
each a primary with streaming standbys. Citus supplies no high availability of
its own, so Patroni is what promotes, and it registers each node with the
cluster unaided. Off unless database.enabled is set, and it stays false in
values.yaml: every deployment that does not ask for it keeps the
DATABASE_URL it already had, and a 0.1.6 values file renders byte for byte
what it rendered before. Two values files turn it on and they are the same
profile, not two — values-node-per-service.yaml is one of everything, five
pods with no redundancy anywhere; values-ha.yaml is three Citus groups at two
replicas with the web tier autoscaling. Everything in the two that is not a
replica count is identical, which is the point: growing from one to the other is
a rollout rather than a migration from one arrangement of the data to another.
That is why there is no separate small-cluster profile, and the Kubernetes
overhead of five pods is what buys never having to do that move.
docs/citus.md is what distributing the ledger costs and
docs/citus-runbook.md is how to run it — adding a worker, rebalancing, what a
failover does, and how to move an existing database onto a cluster.
A third cloud for ha: Oracle Container Engine for Kubernetes.
deploy/pulumi/oci/ builds what the aws and gcp programs build — a network,
a cluster, a node pool, ingress-nginx behind a load balancer, and the chart on
top — so the three differ in what their clouds call things rather than in what
ends up deployed. It is the shortest of the three, and for reasons rather than
by omission: OKE ships the block-volume CSI driver and the load balancer
controller inside the cluster, which the other two have to install, and an OCI
network load balancer preserves the client address without proxy protocol, so
the frontend needs no second way of learning who is asking. The node pool runs
on Ampere, and the image a node boots is looked up from the Kubernetes version
rather than pinned, because a hard-coded image OCID stops existing when Oracle
retires it and the failure is a pool that never produces a node.
simple-balance:controlPlaneCidrs reaches it like the other two, though not in
the same place: EKS and GKE take the allowed sources as a property of the
cluster, while OKE's endpoint sits in a subnet of the deployment's own, so there
it is a rule on that subnet's security list — and that same list is what decides
whether the workers may reach the API at all.
Not exercised with pulumi up. The program plans against a real tenancy and
no cluster has been built from it, which docs/acceptance.md carries as
outstanding rather than leaving to be discovered. That is also why
tests/cluster-control-plane.test.ts exists: a preview plans the security rules
and the cluster cleanly whether or not the control plane can be reached, so the
one failure a preview cannot show — a cluster that reports itself ACTIVE while
every kubelet's registration is dropped at the subnet — is asserted in the
repository instead.
Synchronous replication is derived rather than defaulted. It was true with
nothing tying it to the replica count, and Patroni's synchronous_mode_strict
is off, so a group with no standby degraded silently to asynchronous — a setting
claiming a guarantee it could not give. It is now
synchronousReplication and replicasPerGroup > 1, and asking for it at one
replica fails the render with the reason named. At two replicas it is on for the
reason it always was: a promotion that loses an acknowledged transaction is the
books disagreeing with what somebody was shown.
Patroni's REST API now takes a password, and the database has a
NetworkPolicy. restapi.listen was 0.0.0.0:8008 with no authentication
block and nothing in front of it, so any pod in the cluster could POST
/switchover, /failover, /restart or /reinitialize. Both halves are
closed: a fourth generated password in the database Secret, and a fourth policy
admitting 5432 from the API and the scheduler only and the REST port from
database pods only. The frontend is not a peer of either. Egress from the
database pods stays open on purpose — Patroni keeps cluster state in the
Kubernetes API, whose address the chart cannot know, and a restricted cluster
that loses that rule stops failing over.
A PostgreSQL image this project builds, for the profile that needs one.
deploy/docker/citus.Dockerfile is PostgreSQL 18 pinned by digest with Citus
14.2.0 compiled from a checksummed source tarball, published for both
architectures. It exists because the one arm64 image Citus publishes is musl, and
musl compares text byte by byte whatever collation is declared — which is what
category and payee uniqueness in this application rests on. Its tag names Citus
and PostgreSQL rather than this release, and there is deliberately no latest: a
major version cannot read the previous major's data directory, so a floating tag
on a database turns an image pull into an outage.
The application waits for the database instead of crash-looping at it. On a
first install of the ha profile the API and the scheduler came up before
PostgreSQL was accepting connections and restarted once, or twice at the
redundant shape, before settling — which looks exactly like a broken deployment
to somebody meeting the chart for the first time. Both now carry a
wait-for-database init container, gated on database.enabled, so a
bring-your-own-database deployment is unaffected and renders what it rendered
before.
helm uninstall now takes Patroni's own state with it, so a reinstall under
the same release name works. Without it, helm uninstall followed by
helm install left the database permanently unable to start. Patroni keeps its
cluster state in Kubernetes objects it creates itself at runtime — endpoints
named <release>-simple-balance-db-<group>, and -config, -sync and
-failover beside them. Helm deletes what it created and did not create those,
so the -config endpoint survived carrying an initialize annotation with an
empty value. Patroni reads the presence of that annotation as "this cluster
already exists, do not bootstrap, wait for a leader", and the empty value means
no member can claim leadership from it either. Every database pod logged
INFO: waiting for leader to bootstrap every ten seconds forever, Running and
0/1, with nothing crashing, nothing backing off and no event saying anything
was wrong — and deleting the PersistentVolumeClaims, the obvious thing to
reach for, is what produces it, because the state then describes a cluster
whose data is gone. Deleting the namespace was the only recovery.
A Job now deletes those objects on uninstall. Because a hook that deletes
database cluster state is dangerous by construction, the decisions that make it
safe are written into the template beside the annotations rather than left to be
inferred:
- It is a
post-deletehook, not thepre-deleteone this obviously wants to
be. Helm runspre-deletebefore deleting anything, so every database pod
is still up and Patroni's leader rewritesinitializeand the config object
within one ten-secondloop_wait. Apre-deletehook would delete four
objects and be handed them straight back — it would have looked right and
changed nothing. - A hook that cannot run never strands the release. Helm returns a
pre-deletefailure before the uninstall proceeds, so a broken one would
refuse the uninstall and leave a release nobody could remove. Apost-delete
failure is collected instead: the resources are deleted, the history is
purged, andhelm uninstallexits non-zero. The residue is the four
endpoints, which is exactly the old behaviour, recovered the old way. It does
still fail apulumi destroy, which the runbook anddocs/upgrades.mdboth
cover: the Release is left in Pulumi state with nothing behind it, a second
destroycannot clear it because the history is already purged, and
pulumi state deleteon that URN can. On a teardown that takes the namespace
anyway,database.patroniCleanup.enabled: falsedeclines the hook and the
question with it. - It waits for the database pods to be gone before deleting anything. Helm
does not wait for pods on uninstall and they have a sixty-second grace period,
so a Job that deleted immediately would lose the same race thepre-delete
hook loses. Its wait selector is deliberately broader than its delete
selector: waiting for too much only costs seconds, while deleting too little
reproduces the defect on the next install. - It matches this release and no other. Selection is by the labels Patroni
itself writes —app.kubernetes.io/instance, the release name, and
cluster-name, which contains it — never a name prefix or a wildcard that
could reach a sibling release in the same namespace. It skips Helm-owned
objects, deletes by exact name, and treats a 404 as success, so it is harmless
when the objects are already gone. It fires on uninstall and from nowhere
else: no install, upgrade or rollback path reachespost-delete. - Its RBAC is a hook resource too, not a release resource, since Helm
deletes the release's own objects before runningpost-delete. It is a
namespaced Role carryinglistanddeleteon endpoints and services and
liston pods — no cluster scope, nodeletecollection, and nothing the
database ServiceAccount is not already granted — and it exists only for the
seconds the Job runs.
On by default, and database.patroniCleanup.enabled: false turns it off for the
one flow it changes: reinstalling over intact claims, where the cleanup means
the dynamic configuration is rebuilt from the chart's bootstrap.dcs and any
patronictl edit-config is reverted. It renders nothing unless
database.enabled is true, so a values file that does not run the database is
byte-for-byte what it was. docs/citus-runbook.md §Uninstalling, and the state
that used to outlive it has the whole of it.
A capacity proof, reproducible from this repository. docs/capacity.md is
the claim — ten thousand people's ledgers on the smallest machine the single
profile sells, answered inside stated times — and scripts/capacity/ is what
produces it: a generator that builds thirty million transactions and sixty-six
million postings in SQL, a driver that applies an hour of graded load, and the
thresholds both are held to. The generator writes SQL because thirty million
entries through the service is a load test rather than a setup step, so it ends
by proving what the service would otherwise have enforced: every currency
settles to zero, every individual's books settle to zero, every posting names an
account of its own owner and currency, and every entry moved the amount it says
it moved. It refuses to finish otherwise, because a number measured against a
ledger that does not balance is measuring rows the application could never have
written.
A single deployment profile: two machines, and a PostgreSQL 18 it runs
itself. deploy/compose/single/ is the application container behind Caddy
with automatic TLS on one machine, and postgres:18 on a second with no public
address at all, in a private subnet on the provider's own network. One
pulumi up from deploy/pulumi/aws-single/ or deploy/pulumi/oci-single/
builds both. deploy/systemd/ makes each a service that survives a reboot —
one unit for both machines, switched by COMPOSE_FILE rather than by knowing
which machine it is on — with a daily pg_dump that is verified by being read
back before it is kept, and a restore that refuses a dump it cannot parse before
it touches the database. The backups stay on the application node and are
taken over the network, because a copy on the same disk as the original is not a
backup. Each machine has a data disk that outlives it: the nightly backups, the
generated secret on one, PGDATA on the other, so replacing
either destroys its root volume and keeps what matters; a resize is made in
place. Three new documents say which profile to pick, how big the machines have
to be, and what they cost.
Bringing your own database is still supported, as a setting rather than a
profile. simple-balance:databaseNode: false builds no database node, no
private subnet and no NAT gateway, and the application node waits for a
DATABASE_URL in the stack's simple-balance:secrets — the floor there is
still PostgreSQL 15, because it connects to whatever you already have. The
generated string lives in /opt/simple-balance/env.db at 0600, and
simple-balance-env folds env.base, env.db, secrets.env and the stack's
settings in that order, so a DATABASE_URL of your own still wins on a machine
whose program generated one, with nothing to turn off first.
simple-balance:databaseSubnet, which the branch's Oracle program took for a
managed database's private subnet, is superseded by databaseNode and accepted
rather than refused.
New settings, all optional: databaseSize, which indexes the same sizing
table as size but for the database machine, because the two want opposite
things; databaseMaxConnections, default 50 with a floor of 10, which is what
makes the five PostgreSQL numbers in that table applied rather than advice —
the database node's compose file passes every one of them as a -c flag; and
databasePassword, which generates 32 alphanumerics when unset and holds an
operator's own value to letters and digits, because the value crosses a URL, a
Compose .env and a shell script and an encoding applied in one of the three
works until the nightly backup runs. protectDataVolume now governs both
volumes.
The sizing table sizes the two machines apart, because they hold unrelated
things. One data-disk figure was read twice — once for PGDATA and once for
the nightly dumps — and it was wrong for both at the population
docs/capacity.md proves. A row now carries an application half and a database
half. The application node comes down to two cores and 4 GiB at every size,
which is roughly four times the 0.5 core and 745 MiB it was measured using, and
the four and eight cores it used to buy were bought for a machine that stores no
ledger and whose memory does nothing for the database's cache. The database
node's machine does not change at all. The disks do: medium carries 250 GiB
where it carried 140 and large 680 where it carried 240, which is what it
takes to hold the ledgers those rows claim and the fifteen dumps beside them —
fourteen kept plus the one being written, which is the peak the backup script's
own prune order produces and which every figure here had been understating by
one. No disk in the table got smaller, deliberately: AWS refuses to shrink a
volume and OCI would replace one, so the disks may be made more generous and
never less, and small's application disk stays at 20 GiB where its arithmetic
asks for 10. The application node's machine is the one thing that does come
down, which is safe for the opposite reason: a resize is in place on both clouds
and costs a restart rather than a disk. medium and large cost roughly 30% less than they did
on AWS while carrying nearly twice the disk at medium — 140 GiB to 250 — and
nearly three times at large — 240 GiB to 680. small is unchanged, and an
Oracle small is still $0 at exactly the Always Free ceiling. docs/deployment-sizing.md shows the
arithmetic per machine rather than the answer.
A grown disk becomes grown space by itself. Both clouds enlarge a block
volume in place and leave the filesystem on it exactly the size it was
formatted at, so raising a row used to buy space that was real, paid for and
unreachable. simple-balance-growfs.service runs resize2fs on both machines
at every boot, after the data volume is mounted, and prints Nothing to do!
when there is nothing to do — so a reboot is all a grown disk needs. It is a
unit rather than a line in the first-boot scripts because cloud-init runs those
once per instance and a pulumi up that grows a volume replaces no machine,
which is exactly the case the growth is for. A machine built before this release
never receives the unit, for the same reason, and wants
sudo resize2fs "$(findmnt -no SOURCE /var/lib/simple-balance)" once; the
growpart recipe two documents used to give was wrong on both clouds, since
neither data volume carries a partition table.
The ha chart's wait-for-Citus init container takes its own pod's security
context. It was hard-coded to the server's, so a deployment that hardened
scheduler.containerSecurityContext got the server's settings on the one
container in the scheduler's pod nobody had chosen them for — either a pod a
policy admission controller rejects, or a container an operator believes is
constrained and is not.
simple-balance:databaseEgress, for the AWS NAT gateway that is 38% of a
small bill, with three ways off it. It defaults to nat, which is what
every stack has today and plans no change.
ipv6 builds an Amazon-provided IPv6 range, an egress-only internet gateway and
a ::/0 route instead, and costs nothing — no hourly charge and no per-GB
charge. What it costs in another currency is stated rather than hidden: Session
Manager stops working on the database node, because ssm.<region>.amazonaws.com
publishes no IPv6 address and there is no IPv4 route left, so the setting
requires sshPublicKey and is refused without one rather than quietly removing
the only shell onto the machine holding the ledger.
ssm is the middle answer and is $14.60 a month in a US region, $21.90
cheaper than the gateway, keeping pulumi stack output databaseShell working
exactly as documented and needing no SSH key. It is the IPv6 egress-only gateway
plus com.amazonaws.<region>.ssm and …ssmmessages interface endpoints, and
building both halves as one setting is the point rather than an implementation
detail: interface endpoints reach AWS services, and neither Ubuntu's archive nor
Docker Hub is one, so endpoints without the gateway would produce a machine
that comes up running, answers Session Manager and never finishes installing
Docker or pulling postgres:18 — the shell you paid for being the only part
that works. Every combination of settings that cannot work is refused at plan
time, naming what is missing. The one condition that is not a setting — whether
the availability zone AWS gave this stack offers both services — is refused
against the same message, at pulumi preview on a stack whose subnets already
exist and during pulumi up, before the first endpoint and before either
machine, on one that has none yet.
ec2messages is deliberately not built: from SSM Agent
3.3.40.0 onward Systems Manager prefers ssmmessages, regions launched from
2024 support only ssmmessages, and AWS is retiring the ec2messages endpoint
itself on 2026-09-30 — so a third endpoint would be $21.90 a month, $7.30 of it
for a service being switched off.
The endpoints' security group admits 443 from both node security groups,
because an interface endpoint's private DNS overrides the service name for the
whole VPC rather than for one subnet, and admitting only the database node would
have silently taken Session Manager away from the application node. $14.60 is
us-east-1 and us-west-2; docs/deployment-costs.md prices both sides region
by region, because the NAT gateway's hour is regional too and moves further than
the endpoint's. The saving therefore grows outside the US rather than shrinking:
in sa-east-1 two endpoints are $30.66 against a gateway that costs $71.54 there,
which is $40.88 a month and, priced across all 34 commercial regions, the
largest saving this setting offers anywhere — while the US $21.90 is the
smallest of any of them.
Oracle Cloud needs none of it, since its NAT gateway is free.
Customer-managed keys on both single-machine clouds, accepted and never
created. simple-balance:kmsKeyArn on AWS and simple-balance:kmsVaultOcid
with simple-balance:kmsKeyOcid on Oracle Cloud encrypt both data volumes and
both boot volumes with a key of yours. Unset — the default — every one of them
is still encrypted with the provider's own key, free and undeletable, which
stays the right answer for most deployments. The program accepts a key rather
than building one because a key a program creates has the program's lifetime,
and pulumi destroy --exclude-protected is this profile's documented teardown
and deliberately keeps both data volumes: a key the program owned would be
scheduled for deletion by the same command that kept the ledger. Both programs
read the key at plan time and refuse the stack before anything the key would
encrypt is declared unless it is enabled, customer-managed, symmetric and for
encrypt/decrypt, so a wrong key fails a preview rather than building half a
deployment around it — the network can still be built first under
--skip-preview, which is said plainly rather than rounded up to "before any
resource". It is not retroactive: a volume's key is fixed at creation, so on
AWS setting it on a stack that exists plans to replace the volumes. The guard is
a setting of its own, simple-balance:kmsKeyArnIsNewStack, which kmsKeyArn is
refused without — not protectDataVolume, because protect is a flag in
the state snapshot and pulumi state unprotect clears it there while the config
still reads true, so a guard built on the config would have passed in one of
the two states where Pulumi goes ahead and deletes a ledger. On Oracle Cloud a
key added later reaches all four volumes, boot volumes included, because both
instances name individual properties in ignoreChanges rather than the parent
that holds the key. docs/deployment-profiles.md §Encryption has what the key
policy must grant — GenerateDataKeyWithoutPlaintext and CreateGrant, which
no check can see and whose absence is a volume created and deleted seconds later
— and the lock-out behaviour on each cloud and the recovery, including that on
Oracle a key in pending deletion cannot be unassigned, so the deletion has to be
cancelled first.
Neither machine carries a secret it does not need. AUTH_SECRET is
generated on the application node at first boot and kept on its disk; the
database's superuser password is generated on the database node and kept on
its disk, and only when the file is absent, so a rebuilt machine cannot rotate
itself out of its own cluster. Neither is in user data or a state file. The CA's
private key reaches neither machine and exists only in Pulumi's state. The one
password the stack does hold is the unprivileged application role's, which has
to reach the machine that puts it in a connection string.
Every setting is the stack's, kept in the cloud's own secret store. SMTP,
Google sign-in, Stripe, AdSense, the legal pages and every other setting the
application reads are simple-balance:env and simple-balance:secrets in the
Pulumi stack, the secret ones encrypted there, in all five programs. On a single
machine the program writes them into one OCI Vault or AWS Secrets Manager secret
and grants the application machine's own identity read on that secret and
nothing else; the machine fetches it every time the deployment starts and checks
every five minutes, so pulumi up changes a setting with nobody logged in, and a
rebuilt machine has every setting it had without anybody having kept a copy.
Nothing the stack sets travels in user data, which anyone who can describe the
instance can read. On a cluster the plain half reaches the chart's values and
the secret half the Kubernetes Secret the program builds, which EKS and GKE
encrypt with a key of their own — and billing, ads, mail and Google sign-in,
which those programs could not turn on at all, are settings like any other.
pulumi preview refuses a name the application does not read, a secret in the
plain map, and a name a program decides itself. A .env file fills a stack in
one command with deploy/pulumi/settings-from-env.mjs, which reads the file
literally and leaves out what describes where a development copy runs. The
shared unit in deploy/systemd/ carries no such step, so a machine installed by
hand edits /opt/simple-balance/.env and restarts, as before. The Oracle program
takes simple-balance:availabilityDomain — a full name, or a number from 1 —
for the launch that fails with Out of host capacity. It requires
simple-balance:sshPublicKey, and reaches the application node through OCI's
Bastion unless simple-balance:sshCidr opens port 22 to one address, and the
database node through a Bastion in that node's own subnet. Its public address is
ephemeral, and a rebuild changes it.
The application node waits for the database before Compose is asked for
anything. Both machines boot at once and the database node takes about four
minutes longer — it formats a volume, initializes a cluster and runs its own
first-run SQL — while the application node is ready in ninety seconds. Without
the wait, docker compose up -d --wait found nothing on 5432 and abandoned the
rest of the project on its way out, so Caddy was created and never started: a
healthy application on loopback, nothing on 80 or 443, and a unit in failed
that nothing retried because ExecStart had already run. The wait does nothing
where there is no remote database to wait for, because being wrong in that
direction costs one boot and refusing would strand a machine that worked before
the check existed.
Encryption at rest and in transit, stated as properties rather than left to
defaults. Every volume either single-machine program builds sets it
explicitly — encrypted: true on both AWS data volumes and both root devices,
four occurrences where there were two, because EBS encryption-by-default is an
account setting that is off on a fresh account and the property is the whole
guarantee. On Oracle Cloud there is deliberately no kmsKeyId and a test
asserts its absence, so "OCI encrypts every volume with an Oracle-managed key"
cannot quietly stop being what the program rests on.
isPvEncryptionInTransitEnabled is set on both instance launches and both
volume attachments, which was a real gap. On EKS the programs now install the
aws-ebs-csi-driver add-on with its IRSA role and an encrypted gp3
StorageClass — without the driver a claim from the database StatefulSet sat
Pending forever — and turn on envelope encryption of Secrets in etcd; GKE gets
the Cloud KMS equivalent and a StorageClass of its own. No customer-managed key
on any data volume, deliberately: a key policy to get wrong, a monthly charge,
and a documented way to lock yourself permanently out of your own ledger.
And the hop to the database is verified, not merely encrypted. Both
single-machine programs issue a private CA and a server certificate with
@pulumi/tls at plan time, and the generated DATABASE_URL is
sslmode=verify-full with an sslrootcert the overlay already mounts. The
certificate's SAN is the provider's own internal name for the database node —
db.db.simplebalance.oraclevcn.com, or ip-10-20-1-10.<region>.compute.internal
with the us-east-1 ec2.internal spelling branched for — which is knowable in
advance because the node's private address is pinned. The URL names that name
and never the address: node-postgres sends no server name for an IP literal, so
verify-full against one checks the certificate against localhost and fails
however many IP SANs it carries. The server is what insists: pg_hba.conf is
mounted and named with -c hba_file=, every network line is hostssl, and the
superuser is refused over the network entirely — the application signs in as an
unprivileged role that owns only its own database. The chart does the same
inside the cluster, with verify-ca for Citus's node-to-node and replication
traffic, because Patroni registers members by pod address and a name check would
fail a healthy cluster.
Only what has to be reachable is. On both clouds the application node opens
80, 443/tcp and 443/udp to the internet — it is the edge, because Caddy answers
the ACME HTTP-01 challenge there and this profile has no load balancer — and 22
only behind simple-balance:sshCidr. The database node opens 5432 to the
application node and nothing else: one source-security-group rule on AWS, one
subnet CIDR on Oracle Cloud, and not one rule from 0.0.0.0/0 on either. It has
no public address twice over, the subnet refusing one and the instance asking
for none, and no inbound rule for a shell on AWS at all, because Session Manager
works through egress. On Oracle Cloud the host's own netfilter ruleset is opened
for 5432 and nothing else, which is the half of the firewall that an operator
meets as a timeout rather than a refusal.
The Oracle program builds where its stack says, and will not delete its data
volume by accident. It requires oci:region in the stack and stops at
preview, before anything is declared, without it. Left to the provider, the
region came from TF_VAR_region, OCI_REGION or whichever ~/.oci/config
profile ran pulumi up, so where a machine holding somebody's data lived
depended on the shell, and a stack run from another one looked for its
resources somewhere they were not. Any region is accepted, and for Always Free
it is the tenancy's home region, the only one with free Ampere capacity. The
data volume — the generated secret and every nightly dump — is
marked with Pulumi's protect unless simple-balance:protectDataVolume is
false, so pulumi destroy fails at its preview and deletes nothing. A new
simple-balance:availabilityDomain, which would replace the volume, is refused
before the machine is touched, --skip-preview included: Pulumi's own refusal
comes only on reaching the volume, by which point a run without a preview had
already relaunched the machine in a domain the volume cannot follow it to. The
volume is built after the machine, so a launch refused for capacity leaves no
volume behind and the retry in another domain goes through. The cost is at
teardown: pulumi destroy --exclude-protected and --skip-preview delete only
the volume's attachment and leave the machine running without its disk, so
deploy/pulumi/README.md §Tearing down on Oracle Cloud gives the two ways to
mean it. protect binds Pulumi and nothing else; the console can still delete
the volume. The stack exports region and dataVolumeId.
The AWS single-machine program does the same. It requires aws:region in
the stack and stops before anything is declared without it, where it had
accepted the shell's AWS_REGION or AWS_DEFAULT_REGION — and version 7 of
the provider records a region on every resource, so a stack run from a shell
pointed elsewhere planned to replace every one of them there, the data volume
included. Its EBS data volume is marked with protect unless
simple-balance:protectDataVolume is false, so pulumi destroy fails at its
preview, and so does the one change that would replace the volume, a new
region. A resize and a replaced machine go through, since the volume is built
before the machine and takes nothing from it. pulumi destroy --exclude-protected and --skip-preview keep the volume and the VPC and subnet
it was built in and delete the rest, the Elastic IP included, so a rebuild
comes back on a new address; deploy/pulumi/README.md §Tearing down on AWS
gives the two ways to mean it. The stack exports region and dataVolumeId.
Both behaviors were run against the provider itself, on a stand-in of the
program's graph, rather than read from its documentation.
A first boot waits for its data volume as long as Pulumi would: up to forty
minutes, which is how long the providers give the steps between the machine
running and its disk appearing. On Oracle Cloud the volume is built only after
the instance runs, and the provider allows twenty minutes for that and twenty
for the attachment; on AWS a replacement waits out the old machine's stop, the
detach and the attach. A shorter wait could give up on a volume still on its
way and leave a machine with none of the directories the next steps write into.
A volume that arrives sooner is used at once, and the script says what it is
waiting for when run by hand.
The single-machine programs' instructions verify the database. Both
nextSteps install the CA certificate at
/var/lib/simple-balance/tls/db-ca.pem and give a DATABASE_URL of
sslmode=verify-full naming it, as the machine's own /etc/motd does;
sslmode=no-verify still works. Both say to wait for the first boot to finish
before any of it.
deploy/systemd/simple-balance.env documents SB_PG_CA_BUNDLE and when to add
compose.db-tls.yml to COMPOSE_FILE, and both compose recipes with a
frontend pass SB_REAL_IP_RECURSIVE through, commented in
deploy/compose/.env.example.
SB_TRUSTED_PROXY_CIDR, without which every visitor shares one sign-in
allowance. The frontend container tells the API which address a request came
from, and behind anything terminating TLS that address was the terminator — the
same value for everybody, so four wrong passwords from anywhere locked out the
rest of the world. Set this to the range the terminator connects from and the
visitor's own address survives the hop. It defaults to loopback, which is the
off position: nothing reaches the container from there, so a deployment that
sets nothing behaves exactly as it did before. Under Kubernetes the chart prints
a warning while it is unset and config.trustProxy is on, replacing a note that
told operators to edit the nginx template and rebuild the image — which nobody
using a published image could do.
It takes a list, separated by commas, spaces or both, for more than one proxy,
and beside it SB_REAL_IP_RECURSIVE, off by default, for a chain of proxies
that each append to X-Forwarded-For rather than one that replaces it —
Google's load balancer is one, and off there takes its address for everybody. A
script the image's entrypoint runs before it renders the configuration turns
each entry into a directive of its own and refuses to start on one that is not
an IPv4 or IPv6 address, a CIDR or unix:, naming it, because nginx would
otherwise resolve a host name at startup and trust whatever it answered, and
read 10.0.0 as 10.0.0.0, without a word in the log. It refuses a recursion
value it would have to guess at, and a /0 entry with recursion on, which would
believe whatever address a caller wrote first. One address or CIDR renders
exactly the configuration it did before the list existed. The chart takes
frontend.trustedProxyCidr as a string or a YAML list, adds
frontend.realIpRecursive, refuses at render what the image would refuse at
start, and renders --set frontend.trustedProxyCidr=null as the off position
rather than as an empty value and a pod that never started. Building the
frontend image now needs BuildKit, for the COPY --chmod that makes that script
executable; the entrypoint skips one that is not, with nothing but a log line.
A plan, sold through Stripe, off by default. Setting the five STRIPE_*
variables makes Stripe reachable; SB_BILLING_ENABLED=true puts a plan on sale
and holds a free account to three financial accounts. The two are separate
switches so that winding a deployment down stops new subscriptions without going
deaf to the ones already running — the state in which somebody is charged for a
plan the app no longer believes they have. docs/monetization.md has the table
of what each combination turns on, and docs/billing-operations.md is the
runbook: setting the Stripe account up in order, going live from test mode,
granting a plan by hand, what a refund does and does not change, and the order
to shut billing down in. This release collects no tax, and the fee arithmetic
in docs/monetization.md counts none.
A plan can be granted by hand, and while one is granted the tab stops
selling. billing_override puts somebody on a plan without Stripe — two SQL
statements against auth_user by email address, one to grant and one to expire,
both in docs/billing-operations.md §Granting a plan by hand — and it already
outranked Stripe wherever the plan is resolved. What it did not do was stop the
plan tab offering to sell what it had just given away: every plan button stayed
live, and a first payment that was never finished kept its button too, so
somebody granted Premium could be charged for Premium. Nothing in the product
showed it — the tab said Premium, the grant note said Premium, and only the
Stripe dashboard disagreed. Now both plan buttons are disabled with a sentence
naming who to ask, the unfinished first payment is withdrawn, and the route
refuses the same requests with 409 and details.planGranted: true rather than
relying on the buttons. The route that collects a first payment refuses it too,
which is a second door and the one that actually charges a card: closing the
subscribe button alone would have left a granted person able to buy the plan
they had been given by replacing their card, which is the same door that had
already been closed once for a deployment that had stopped selling. Cancelling a subscription somebody is still being
charged for stays open, and so does paying off a renewal whose retries ran out:
both stop a charge rather than starting one, and the person a plan was granted
to is exactly the person who may also still be paying for it. An expired grant
sells again at once, with nothing to clear and no session to end — and the note
claiming the grant "takes precedence over anything below" now goes when the
grant does, where it used to stay beside buttons that were live again.
No advertising on the plan and billing tab, enforced in the shell rather
than by the policy. The policy is the obvious place and the wrong one: under
SB_CSP_REPORT_ONLY — the mode an operator is told to run on exactly that page
— nothing there is enforced, so a slot left mounted would have put live ads
beside the payment form on the page that sells their removal.
The ads policy carries what Google's consent message needs. It does not
render in an iframe: it appends into the document and styles itself from
injected blocks and a Google font stylesheet, so under style-src 'self' it
appeared unstyled far down the page, nobody answered it, and in the EEA and UK
no ad request completed — which looks exactly like having no inventory. It needs
a referrer as well, and Google's own troubleshooting says it does not serve under
the same-origin this app sends everywhere. So the pages that can carry an ad
send strict-origin-when-cross-origin, the browser's own default: another
origin is told this site's address and never a page's path, so the record ids
in a URL still stay off every Referer. The plan tab keeps same-origin.
Advertising, off unless an operator asks for it. Setting ADSENSE_CLIENT_ID
and ADSENSE_BANNER_SLOT_ID shows one unit in the application shell, and an
optional second at the bottom of the page. Never on the plan and billing tab,
never on sign-in, and never to anybody on a paid plan — and only where a plan is
for sale, so AdSense on a deployment with SB_BILLING_ENABLED off widens the
policy, serves /ads.txt and shows nobody an ad. The process says so at every
start rather than refusing. PRIVACY_POLICY_URL is required beside the ids,
and docs/monetization.md holds the one ordered checklist for turning ads on,
from the AdSense account to checking both ads.txt files.
The server decides who sees an ad, and the browser is never told the rule.
A session that should show no ads carries no ad configuration at all, so the
page has nothing to build a slot from rather than a rule it has to apply
correctly. That closes both ways this usually goes wrong: the gate cannot be
written as the inverse of "is on Premium" — which would show ads to every paying
subscriber on a deployment that had stopped selling, since nobody is on a
limited plan there — and there is no window between first paint and the
entitlement arriving. Absent reads as "no ads", so every bug in this fails
toward showing nothing. Google's script is fetched by the first slot that
mounts rather than by the document, so a subscriber never loads it, is never
counted as an impression, and is never tracked by it.
The publisher id reaches the browser at runtime, on the session response,
the same path STRIPE_PUBLISHABLE_KEY already takes. One published image serves
every operator with their own account; nothing is compiled in, and there is no
shared publisher id.
Ads are requested non-personalized unless a consent platform is collecting
consent. ADSENSE_CONSENT_MANAGED defaults to false, and then every request
carries requestNonPersonalizedAds — which is what lets a deployment serve ads
with no consent platform at all, since Google gates only personalized ads on
one. It is also the right default on its own merits: the page beside the ad is
showing somebody their own balances.
An operator who wants personalized ads uses AdSense's own Privacy and
messaging, which is a certified platform, free, and part of the account they
already have — no second vendor, no second contract, and nothing added to this
application, because Google's ad tag delivers the consent message itself and
that tag is already loaded by the first ad slot that renders. The setting then
stops forcing the flag and lets the platform's answer decide, which is the whole
point: forcing it on top of a platform would override somebody who consented as
surely as it protects somebody who did not.
/ads.txt is served automatically, derived from the publisher id. Without
it AdSense treats the inventory as unauthorized and pays nothing, which is a
failure with no symptom inside the product: the ads render, the impressions
happen, the revenue is zero. An operator selling through other partners serves
their own file at the edge.
What serving ads costs the policy is now real rather than promised. AdSense
publishes no list of the hosts it loads from, so allowing it means allowing
scripts, frames and connections to any HTTPS origin, plus unsafe-eval, on
every page but the plan tab. That was documented for a release before it was
implemented — the settings were accepted, the cost was described, and the app's
own policy blocked every unit. Both transports carry the axis now, and
tests/security-header-parity.test.ts compares them. 'unsafe-inline',
base-uri, form-action, frame-ancestors and object-src are unchanged.
A tab strip across Settings, with Preferences and Plan and billing as two
pages of one section. It navigates with plain anchors rather than the client
router, which is the one place in the app that does: the two pages are served
under different content security policies, and a policy belongs to the document
it arrived with. Leaving the plan tab is a document load too, however it is
reached — otherwise its wider policy would follow you onto every page that
renders balances.
SB_BILLING_CONFIGURED on the frontend container. In the split deployment
nginx serves the plan tab's document, so nginx decides which content security
policy it arrives with. It is keyed on whether Stripe is configured, not on
whether a plan is for sale — an operator winding down keeps the Stripe settings
while their subscribers go on being charged, and replacing an expired card has
to keep working on that page. The compose recipe derives it from
STRIPE_PUBLISHABLE_KEY so the two cannot disagree.
SB_CSP_REPORT_ONLY, a rehearsal for the plan tab's policy. That policy is
Stripe's published set for Stripe.js and its set for Link, plus four hosts this
project added — *.hcaptcha.com, m.stripe.com, q.stripe.com and
errors.stripe.com — and whether a live account contacts those four has not yet
been observed, because no live account has run the form. Setting this makes that
page report what its policy would have blocked and block nothing, and registers
POST /api/csp-report for the reports. It reaches that one page: every other
page goes on enforcing, because learning about a page that renders no balances
is not worth taking the defense off every page that does. That includes every
page that can carry an ad, so it reports nothing about AdSense; what the ads
policy refuses is read from the browser console on a page with an ad. Both
deployment shapes honor it, and tests/security-header-parity.test.ts now
compares the TypeScript and the nginx spelling across both surfaces and both
modes — a comparison that ran only for the default surface before, which is how
the split deployment shipped a plan tab whose payment form could not load.
A plan and billing tab, at /settings/plan. It shows what the account
includes, how much of it is used, what it costs — read from Stripe, not from
this deployment's settings, so the figure on the screen is the figure that gets
charged — and changes it. Monthly to annual takes effect immediately and charges
the difference; annual to monthly waits for the renewal, because the period
already paid for is not this software's to cut short. Canceling always means
"at the end of the period", in both directions.
The tab is a separate document rather than a panel on Settings, and the link
into it is a plain anchor. Stripe's payment form loads a script and an iframe
from Stripe, which every other page in this app forbids, and a content security
policy belongs to the document it was served with — so a client-side navigation
would keep the strict policy and the form would never appear. Every other page
keeps the default-src 'self' policy this container has shipped since 0.1.0,
byte for byte. Stripe's script loads on that tab alone, and only once there is
something to confirm, so no other page fetches it — sign-in included — and
Stripe's fraud-prevention cookies are set nowhere else.
A failed renewal keeps Premium for fifteen days from the failure while Stripe
retries, which is sized to Stripe's recommended default of eight tries within
two weeks with a day over for a late webhook; a retry window of three weeks or
more outlasts it. While a renewal is failing the tab offers Pay now, which
confirms the open invoice in the page — the way to pay, from here, a charge the
bank wants authenticated, since a replaced card pays off-session and 3-D Secure
refuses exactly that. It works while nothing is for sale too, and so does
Pay what is owed on an unpaid subscription: paying an invoice that already
exists sells nothing. Asking an unpaid subscription for the other interval is
not a payment but a switch scheduled for the renewal, so it is refused then like
every other change of interval. Letting go of a switch still waiting sells
nothing either, and the tab keeps a button for it. GET /api/v1/billing reports
subscription.payable, and the tab offers a pay button only where it is true,
so Finish your payment on an unfinished first subscription is hidden while
nothing is for sale. docs/billing-operations.md §Setting up Stripe says which
Stripe settings to match it with, what each "if all retries fail" choice looks
like here, and how to go live from test mode.
The two price ids are checked against the plans they are sold as. Each has
to be recurring, bill every one month or every one year to match its setting, be
active while a plan is for sale, and sit in the secret key's own mode, and the
two have to share a product and a currency. A price_ prefix is all the
configuration check can see, and swapped ids, a one-time price or a test price
beside a live key each pass it and go unnoticed until somebody is charged the
wrong amount or checkout fails. So where one does not fit, the log says what is
wrong, GET /api/v1/billing reports nothing for sale, and starting a
subscription answers 409 and charges nobody until the prices are fixed;
replacing a card, canceling, paying a renewal's open invoice and letting go of a
pending switch go on working. A Stripe that cannot be reached to check is a
warning and refuses nothing. The check runs at startup in both the API and the
scheduler, so a wrong id is in the log before anybody opens the plan tab, and
again whenever the prices are read, before every subscription is started, and on
every reconciliation sweep.
And what a restricted key may read is checked at startup too. A key built
from docs/billing-operations.md step 2 is asked once, in one round of
parallel calls, for a single item of each of the seven things this deployment
reads — customers, subscriptions, subscription schedules, setup intents,
invoices, payment intents and prices — and every one it is refused is named in
a single error line. It writes nothing, refuses nothing, and asks a standard
sk_ key nothing at all, because such a key has no permission to be missing.
The reason for it is that Stripe does not reliably name the permission it
wanted: a key without PaymentIntents answered a first subscription with "An
unknown error occurred", and the only way to learn which of eleven grants was
short was to try them. Reads are all a probe can prove, so the other half is
per call: a call refused for a missing permission now logs the call, the code
and the fix, and a call Stripe holds for a person to approve says that
instead — which is what an agent-tagged key does, on a deployment with nobody
to approve it. Step 2 now says where that tag comes from, which is a choice
made in the form that creates the key rather than anything a replacement key
escapes; that Stripe's default approval rules cover exactly two actions, a
refund and a cancellation, of which this deployment makes only the second, in
one place; and the two ways out, a key created without that intended use or
the "Subscription is canceled" rule deleted under Settings → Approvals →
Rules. The refusal line names both of those too, where it used to name only
the key and so sent an operator to rotate a working one. Both are checkable
rather than invisible, which is what answers is my replacement key tagged as
well: a tagged key carries an Agent badge in the API keys list, and the
rules are on the Approvals page, where any can be deleted. Do it again in live
mode either way — a key belongs to one mode, though whether a rule does is
something Stripe does not say, so that page is worth a look there too. That
table also
gains PaymentIntents: Write and PaymentMethods: Write, neither optional:
the first is what lets a paid renewal keep the card that paid it, and the second
is what lets a replaced card become the one Stripe bills.
The plan tab states its renewal terms beside every request to pay. Beside
the Annual and Monthly buttons, beside the payment form's confirm button for the
plan being paid for, beside Keep my plan, which turns renewal back on, and
beside Stay on the annual plan (or monthly), which lets a scheduled switch
go and so decides which price the next renewal charges, a
boxed paragraph says that Premium renews automatically until canceled; what each
plan charges and how often, in the figures Stripe returns and never one written
into the page; that if the price changes, or tax is added to what a renewal
costs, an email comes between 7 and 30 days before the change takes effect,
saying what it will cost and how to cancel; and that canceling is Cancel at
period end on this tab, with the plan running to the end of the period paid
for. Each of those buttons is described by it, so a screen reader reads it on
the button as well. California's Automatic Renewal Law asks for these terms
conspicuous and beside the request for consent, which is a place no terms page
can reach, and it sets the 7-to-30-day window. A plan Stripe cannot price at
that moment is not offered, and the tab says which, because its terms could not
say what it charges; paying what is owed, replacing a card and canceling need no
price and still work. The email is a promise this software makes and does not
keep: nothing here sends one when a price changes, so an operator who changes a
price sends it, and docs/monetization.md says so.
Your privacy policy and terms of use, where somebody signs up.
TERMS_OF_USE_URL is new, optional, and an absolute https:// address like the
PRIVACY_POLICY_URL beside it. Either one, once set, is linked from the sign-in
and sign-up screens and from the sidebar of every page. With terms set, the
sign-up form and the Continue with Google button each say, above the
button, that creating an account accepts them, and the plan tab links them from
its renewal terms. PRIVACY_POLICY_URL is
published whenever it is set, not only while AdSense is configured, which is
where it was read for most of this release: a deployment selling a plan without
advertising linked its policy nowhere, not beside the form that collects an
address and not beside the tab that takes a payment. Both names are
unprefixed, against the SB_ rule, and whether they stay that way is still
open until this release ships: docs/standards/operations.md §Naming records
the question.
Five billing routes, all session-only. AGENTS.md now names three MCP
exceptions rather than two: paying for the deployment is account management, and
an MCP token is a credential handed to a program. What an agent needs in order to
explain a refusal it meets — the plan, its ceiling and how much of it is used —
is on whoami.
A reconciliation sweep, on the scheduler's existing tick. Every live
subscription is re-read from Stripe when nothing has been heard about it for
twelve hours, fifty per tick, oldest first, so a webhook endpoint that was
misconfigured for a weekend repairs itself within a day of being fixed. It
returns without touching the database where no Stripe is configured, which is
the default.
Two metrics for the Stripe seam — simple_balance_stripe_requests_total and
simple_balance_stripe_request_duration_seconds, labeled by operation and
outcome and by nothing else — and simple_balance_billing_sweeps_total, which
carries an off outcome so a deployment that sells nothing is distinguishable
from one whose sweep has stopped.
Changed
What this product says it is, on every surface that says it. A competitive
read in October 2026 found that the lines this repository led on were lines
somebody else also runs. "Know where your money is, and where it went" opened
the README and the sign-in screen, and PocketSmith, Tiller, Quicken and Empower
all run it. "Every account in one place" was feature A1 in the product kit, and
eight competitors lead on it. What the comparison left standing is the part
this product has and they describe differently: a figure here can be taken
apart. So docs/product/features.json promotes numbers-that-tie-out to A1
and promotes the two features that are its evidence, the account register and
the audit history, from tier C to tier B; the capability list in
docs/product/facts.json is reordered to lead on the same thing; and the
README, index.html's description and the sign-in screen are rewritten around
it. The marketing site at smpl.money reads both product files, so this is the
half of the change that reaches a reader.
Every figure on a report links to the thing it is about. A row naming an
account opens that account; a row naming a category opens that category; and
both carry the range you were looking at, so the page you land on shows the same
window. The three kinds of row with no subject — income and expenses bucketed by
kind, cash flow's segments, and the uncategorized row — stay as plain text,
because there is nothing behind them to open.
A category now says what it cost you. Its page had a header, a date range
and then straight into the list, which on the page you open to ask what a
category costs reads as one that failed to load its middle. Two figures now, for
the range you are looking at and for all time, split by currency where a
category holds more than one.
A row offers its actions the same way everywhere. There were four shapes
across eight lists: a menu, a menu plus one or two icons, three bare icons, and
a full-width button naming the row it sat on. There are two now — the actions
you repeat as icons, the rest behind the menu after them, and a row with only
two actions putting both in the menu. Two "Delete Groceries"-style buttons
became the trash icon every other per-row delete already used, which also
narrows the widest column on two tables.
A refusal and a failed load no longer look like the same thing. A refusal
appears beside the control that caused it, above the filters. A list that could
not load now says so where the list would have been, instead of hoisting the
one sentence that explains it above the filters and leaving the page looking
finished and empty.
The plan tab says what the money buys, which until now was half the answer.
It described both plans by account count and the word "ad" appeared nowhere on
it, while the pricing page's whole argument is that the paid plan adds no
feature: it lifts a limit and removes advertising. A paying customer reading
this screen was told one of the two things they pay for. Both plans now name
the advertising, and the upgrade panel opens on "Every feature is on both
plans" before it gets to how to stop paying.
GET /api/v1/billing carries a new advertises for it, read from the
deployment's configuration rather than from the reader's entitlement.
getAdPlacement answers a different question — show an ad to this person
now — and is null for every subscriber, so reading that would have taken the
claim away from the one person paying to be rid of them. The field is additive
and optional in the browser's own type: a deployment with no ADSENSE_CLIENT_ID
and a bundle served by a container from before the field existed both leave the
sentence out rather than promising there are no ads.
The same line stopped saying "up to 3 accounts" and now says "up to 3
accounts in use at once", which is the limit this product actually enforces.
The shorter phrasing is the one freezing replaced, and the marketing site
corrected it while this tab did not.
Three surfaces said statements arrive on their own, and none of them do.
"Bank statements that import and file themselves" was in the README twice, in
index.html's meta description, in features.json as the name of feature A2,
and on the sign-in screen — which is the first thing a prospective user sees.
There is no bank connection: the importer is a file picker with a column
mapper. Each of those now describes the file you downloaded, and the README and
the feature's own description say outright that nothing arrives from your bank
by itself. The site has had a rule against implying a connection since it was
written; this repository had no such rule and was the source the site was
rewriting from.
The AI capability is a capability rather than the reason to choose this.
docs/roadmap.md has recorded since August 2026, from protocol-level
verification rather than from marketing, that agent access is not the
differentiator it looked like, and features.json already ranked agents last
in tier C. The README had not caught up: it joined the MCP server to the
double-entry promise in one paragraph, which reads as one claim in two halves.
It now says which half is which, and features.json records in the feature's
own why that PocketSmith ships the same idea down to the permission levels.
A free plan now freezes the accounts it cannot keep active, instead of
letting you keep using all of them. Somebody who drops to the free plan
with more than three accounts keeps every one of them and chooses three to
keep using. The rest are frozen: still listed, still readable, still
counted in every balance, report and export, and closed to every change — no
new entry, no edit, no delete, not even a rename. Choosing is one operation
over the whole set, PUT /api/v1/accounts/active or set_active_accounts for
an agent, because swapping which three are live is one decision and a switch
per account would make somebody pass through a state their plan forbids.
Nothing is frozen on a deployment that sells nothing, which is every install
arriving from 0.1.6 — but turning selling on freezes, at once, every account
past the oldest three of anybody who has more, so docs/upgrades.md says to
grant an override first to whoever should keep them all.
A frozen account says so everywhere it can be reached. Its own page carries a
Frozen badge and no Add transaction, a new entry never defaults to one, a CSV
import neither defaults to one nor offers one, and the account pickers in both
bulk edits leave frozen accounts out; where every account is frozen, Add
transaction is disabled with the sentence the server would refuse with, and the
import page says so instead of offering a form.
The choice is made once. An account you are using stays that way until you
archive or delete it, and only then can a frozen one take its place. Parking
one to make room for another would be having them all a few minutes at a time,
which is the same as not having a limit. The cap counts the accounts you are
using rather than every account you ever opened, so archiving or deleting one
really does free a place — and coming back out of the archive needs a free
place too, which is what keeps the quota from being cycled. A restored account
takes that place and arrives in use, and while none is free the Accounts page
disables Restore with the refusal's own sentence. Creating, archiving,
restoring, deleting and choosing all hold one lock over the person's accounts,
so two requests racing for the last place cannot both have it.
There is no choice while nothing is frozen. On a plan with no limit, or while
every live account fits within the one there is, set_active_accounts and
PUT /api/v1/accounts/active answer 409, except for a list naming exactly the
accounts already active, which changes nothing and succeeds. Accepting a set
then would be a capability only an agent had, since the Accounts page shows no
chooser there, and a choice written on the paid plan would bind silently at the
next downgrade. The tool is annotated destructive, so a client may ask the
person before it runs: while the choice is open it decides which accounts they
keep, and its description tells an agent to confirm that with them first.
You are asked again when the question changes. A spell on Premium leaves any
account you opened while the limit was lifted sitting beside a choice you made
about a smaller ledger, so the next lapse puts the choice back rather than
freezing an account nobody ever asked you about. Archiving one of the ones you
are using is the other case and is not the same: that frees a place, and only
a place. Whenever the live accounts fit within three, every one is marked in use
again, on any plan — after an archive or a delete, and before a create or a
restore — so a column left saying "not in use" about an account somebody was
using cannot close the choice at the next lapse and freeze that account instead
of a new one.
Frozen is worked out rather than stored. 0024_active_accounts.sql adds the
choice; frozenAccountIds combines it with the entitlement, and it has to be
that way around because entitlements change with nobody present — an override
expires at a moment no code observes, and a deployment that stops selling
answers "no billing" while Stripe goes on charging its subscribers. Until
somebody chooses, the oldest of the accounts marked in use stay usable and the
rest are frozen. The first time a plan limits somebody, every account is marked
in use, so those are the oldest of them all. That costs no write at all, and it
is what makes a subscription lapsing at three in the morning correct rather than
merely handled.
The Overview and the Reports page lead with your own currency. Both group
money by currency and both sorted the groups by code, so somebody holding
dollars and a euro account kept for one trip met the euros first — at the top of
the Overview, where the first heading is the largest figure on the page. The
currency set in Settings now leads and the rest follow alphabetically behind it.
Only the order changes: every figure is what it was, and no total, balance or
report value moves. The rule is compareCurrencies in src/shared/domain.ts,
asked by both pages, because two screens ordering the same ledger differently is
a defect even when both are right.
Every profile that deploys a database now deploys PostgreSQL 18. Two
questions were being answered as one. What this application will connect to is
a floor and it has not moved: PostgreSQL 15 and up, so a deployment already on 15
or 16 keeps working and is not asked to move. What we deploy where the
deployment owns the database is a choice, and it is now the newest version both
profiles can share. The cluster decides it: Citus 14.2 is the newest Citus
and accepts 16, 17 and 18. Every release is now tested against both ends rather
than the middle. Debian rather than Alpine in both, because musl compares text
byte by byte whatever collation is declared, and category and payee uniqueness
here rests on the database's collation.
compose.distributed.yml keeps 0.1.6's postgres:16-alpine as its default,
and takes 18 as an opt-in. It is the one recipe here that ships a database and
the one that is not a profile, so it is the one an operator upgrades in place
with a volume already on disk — and a PostgreSQL container cannot read the
previous major version's data directory. Moving the default would have met that
operator with a database that refuses to start, behind restart: unless-stopped
and the health gate the server and the scheduler wait on, which is a break a
note in the changelog does not excuse. POSTGRES_IMAGE and
POSTGRES_DATA_MOUNT take 18 together, after the dump and restore in
docs/upgrades.md; the default moves in a later release, once the deprecation
has been in the field.
The chart's envFrom takes a list of Secrets rather than one. It had to:
with the database in the cluster, the chart derives a DATABASE_URL of its own
while an operator's existingSecret still carries AUTH_SECRET and the rest,
and the earlier rule made those two mutually exclusive — which is why
database.enabled was unreachable from either Pulumi program. Both are
permitted only when the chart runs the database, and the order is pinned with
the operator's Secret last, because Kubernetes lets a later source win and
theirs must. Every combination that rendered before renders byte for byte what
it rendered before.
The Citus authinfo job is an ordinary release resource, not a post-install
hook. As a hook it was created only after Helm had finished waiting on
everything else — while the server's first migration was waiting on
pg_dist_authinfo to propagate DDL to the workers. Each was waiting for the
other, and pulumi's Helm release gives that 900 seconds before it gives up.
It now runs alongside the rest, named by the release revision.
The three cluster programs take simple-balance:database, a closed set of
external or in-cluster, defaulting to external — which is exactly what
every release so far did, so an existing stack plans no change. in-cluster
turns the chart's database on, refuses a databaseUrl set beside it at plan
time rather than three minutes into a rollout, and derives the connection
ceiling from the chart's own max_connections instead of a stock PostgreSQL's
100. All three also take simple-balance:controlPlaneCidrs, which narrows the
Kubernetes API endpoint and is unset by default, because a wrong guess locks a
stack out of the control plane it would need to fix itself.
Four migrations run at startup, and on one PostgreSQL none rewrites a row.
0022_plans_and_billing.sql creates the five billing tables and alters nothing
that exists. 0024_active_accounts.sql adds ledger_account.active with a
constant default of true, which is one catalog change on any PostgreSQL this
release supports, and every existing account arrives marked active.
0025_subscription_cancel_at.sql adds billing_subscription.cancel_at,
nullable and with no default, which is metadata-only for the same reason — and
on a deployment that never set a Stripe key it is a catalog change to an empty
table. 0023_citus_distribution.sql does nothing on most deployments: it
distributes the ledger across a Citus cluster and is gated on the extension
being installed, so the single profile's plain PostgreSQL 18, and any database
you bring, record it as run and keep the schema they had.
All four were verified on PostgreSQL 15 and 18, from an empty database and from
one 0.1.6 left, and again on 18 once 0025 joined them: twenty-six migrations
recorded, and every primary and foreign key exactly as 0022 left it. On a
cluster 0023 rewrites fourteen primary keys to carry the owner, rebuilds the
indexes on them, and drops five unique constraints the new key makes redundant.
It is one transaction: it either distributes everything or changes nothing, and
it is safe to run twice — a second gate stops it on a ledger that is already
distributed and says so, because moving an existing database onto a cluster
means running this file by hand.
docs/upgrades.md has what each means for rolling back.
Deleting a category group no longer depends on which foreign key is
installed. The service cleared the categories' group by leaving it to
on delete set null, which a Citus cluster cannot use when the distribution
column is part of the constraint. It now clears the column itself, which is
identical behavior on a single database and the difference between working and
failing outright on a cluster.
The release script is no longer told which files pin an image; it is asked.
Both halves of the check walked a hardcoded pair, so a third file pinning one of
this project's images was checked by nothing — it would pass the suite while
quietly deploying whatever release it was written during. tests/version.test.ts
now sweeps the repository for pinned references and fails until set-version
rewrites every file carrying one. Adding the single profile found the gap
immediately, which is the point.
It writes twenty files now, and the test runs it over a scratch copy of every
one of them to prove it, rather than only reading the tree afterward. Two sets
had been out of its reach. A compose file on this branch pinned its images as
${SB_VERSION:-0.1.6}, which neither the rewrite nor the sweep could see, so a
cut would have left it deploying 0.1.6; every pin is written out now and the
sweep refuses that spelling. And the product kit in docs/product/ names the
release it describes, which nothing wrote, so the first cut would have failed
three kit tests with nothing in the procedure saying why. set-version stamps
the kit rather than rebuilding it, which is honest only because release-prep
rebuilds it on the tree being cut — so docs/upgrades.md §Cutting a release now
asks for that check, and for the step that gives the provisional upgrade note
its number and opens the next one.
Pressing a plan button means one of seven things, decided in one place.
The browser previews that decision and the server enforces it, from the same
function, the way refunds already work. It arrived after an adversarial
audit found four defects in the branch it replaces, all of them in corners no
test covered: an unpaid subscription handed back the wrong interval's invoice,
so pressing "Monthly — $3" could charge $30; a second downgrade press sent
Stripe a request it refuses, leaving the plan tab with no working control at
all; somebody on a price the deployment had stopped selling was read as
"Monthly" and could be charged immediately for a switch that should have waited;
and an upgrade on a card that was already failing added a proration charge on
top of the invoice it was failing. tests/subscription-action.test.ts walks
every Stripe status against both intervals.
Replacing a card now replaces the card. Confirming a SetupIntent attaches a
payment method to the Stripe customer and changes nothing about what is billed —
a subscription's own default outranks the customer's — so the new card sat
unused while Stripe went on retrying the dead one. The browser also confirmed it
with confirmPayment, which Stripe.js refuses outright for a SetupIntent. Both
halves are fixed, the server pins the card and pays the outstanding invoice, and
there is a /api/v1/billing/payment-setups/confirmations route and a
setup_intent.succeeded delivery so neither a closed tab nor an unsubscribed
webhook endpoint leaves it half done.
The split deployment serves the plan tab's policy. nginx serves the
application shell itself in that shape rather than proxying it, so the content
security policy the plan tab arrives with is nginx's to set — and it was serving
the strict one, which blocks js.stripe.com. The payment form could not load at
all. SB_BILLING_CONFIGURED now reaches the frontend container, and both spellings
of the path get the wider policy, because the browser's router renders the tab
for /settings/plan and /settings/plan/ alike.
Stopping selling no longer hides the way out. /api/auth/methods reports
billingAvailable from whether Stripe is configured rather than from whether a
plan is for sale, so turning SB_BILLING_ENABLED off stops new subscriptions
without boarding up the only link to the page where somebody cancels or replaces
an expired card — while Stripe keeps charging them.
Deleting an account fails closed on money. The Stripe customer is deleted
first, which cancels everything it owns, and the account deletion is refused
with a 409 if that cannot be confirmed: when Stripe cannot be reached, and
when a key that cannot vouch for it says there is no such customer, as a test
key says of every live one. The billing_customer row cascades away with the
account, so a subscription that outlived the deletion would belong to nobody:
still charging a card, invisible to the sweep, unreachable from anything left in
the database.
STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET take a _FILE form, joining
the seven that already did and taking that list to nine.
/api/billing/* answers 404 rather than the single-page shell. A webhook
aimed at a misspelled path used to get a 200 and an HTML body, which Stripe
records as delivered — a missed delivery nothing ever retries.
The plan tab's policy carries Stripe's Link hosts, and its opener policy lets
Google Pay finish. The Payment Element offers Link by default once the domain
is registered with Stripe, and link.com and *.link.com were missing from
frame-src and connect-src, so a payer with a Link account would have watched
the sign-in fail halfway through the form. And that page alone now sends
Cross-Origin-Opener-Policy: same-origin-allow-popups, because same-origin
severs a popup from its opener and Google Pay, where it completes in a popup,
needs that link to hand the payment back. Both transports carry both, and
neither has yet been watched against a live account.
Under Helm, the frontend's billing and ads switches follow the settings.
The frontend is told Stripe is configured when config.extraEnv carries a
non-blank STRIPE_PUBLISHABLE_KEY or frontend.billingConfigured is true, and
told ads are configured the same way from ADSENSE_CLIENT_ID and
frontend.adsConfigured, as the compose recipes already derive them. Left to
agree by hand, either one disagreeing is a plan tab with no card fields or an ad
slot the frontend's policy blocks, with nothing in the pods to say why; refusing
the render instead would have refused a values file 0.1.6 accepted. The two
switches stay for a key kept in an existingSecret, which the render cannot
see. The list of keys an existingSecret may carry now names METRICS_TOKEN,
STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET as well.
The aws and gcp Pulumi programs take the trusted-proxy settings, as
simple-balance:trustedProxyCidr and simple-balance:realIpRecursive, passed
to the chart's frontend.trustedProxyCidr and frontend.realIpRecursive. Left
unset, each program now supplies its own answer for the network it built,
rather than the chart's off position — the fix for the shared allowance under
Fixed, below. A list of your own replaces the program's whole, with recursion
off unless simple-balance:realIpRecursive says otherwise, because a list
written before recursion existed was written for a header that is replaced; on
GCP as the program builds it that brings the load balancer's address back for
everybody, and deploy/pulumi/README.md says so. Neither program has a billing
or ads setting; enabling either means editing deploy/pulumi/common/index.ts,
which the README spells out.
The refusal a TLS database meets at startup leads with the way that keeps the
check. When node-postgres cannot verify the server's certificate, the message
now says to save the certificate of the CA that signed it and name it,
?sslmode=verify-full&sslrootcert=/path/to/ca.pem, and that the host has to be
a name the certificate carries; sslmode=no-verify comes last, for where there
is no certificate to name. It had recommended no-verify first for any
self-hosted server, which is the one setting that tells nobody when a different
server answers. Where the code rules the CA out it says so instead of blaming
the one named: an expired certificate is sent to a renewal, and a name the
certificate does not carry to the host in the URL. The process refuses to start
exactly as before.
An import, a recurrence's proposals and a staged commit read the plan once,
not once a row. Freezing made every row's validation ask which plan is in
force and which accounts are frozen, which roughly doubled the cost of each row
on a deployment that sells a plan. They are read once per batch now, after the
batch's locks, and a test counts the reads.
The app reads as American English wherever a person or an agent reads it.
The spelling sweep changed words by a map, and a map cannot see an idiom, so a
few survived on screen and in what an agent is told. "Tick the box" is "Check
the box" on Staged transactions and Budgets, a budget whose amount is derived
shows "Calculated" rather than "Worked out", both weekend hints say "Monday
through Friday", the budget form's errors say "percent", the reminder mail says
"one-time", and the tool descriptions an agent reads say "every two weeks",
"at once" and "one-time" where they said otherwise. No argument, field or
stored value changed, so a client that worked still works; only the text it
is shown moved. The page declares lang="en-US", and
tests/american-wording.test.ts holds the phrases it lists at zero across
src, index.html and the product kit.
A reminder's time is written the way the reader's clock writes it. The
templates list and the reminder preview in the template form printed the stored
HH:MM as it was, so a 24-hour "18:30" sat beside a date written the reader's
way. Both now format it in the browser's locale: "6:30 PM" in the US, "18:30"
where a 24-hour clock is the habit. The stored value, and the time field over
HTTP and MCP, stay HH:MM.
The guide has a Plans section, in the words the screens use, and the how-to
walks through choosing which accounts stay usable. tests/product-facts.test.ts
holds that section to the labels and the limit docs/product/facts.json
publishes and to BILLING_GRACE_DAYS, and refuses any other number word there
from zero to twenty but the count of plans and the pronoun "one", so a change to
a label, the limit or the grace cannot leave the guide behind. The MCP guide
names three things an agent cannot do rather than two.
docs/product/facts.json names no $schema, because no address serves
one and anything that followed it met a 404 page. tests/product-facts.test.ts
now compares the whole file with what scripts/build-product-facts.mjs writes,
rather than only the fields it names, so a hand edit fails. The marketing
site's copy loses the key on its next sync.
Fixed
Thirty-five security advisories are closed by this release — eleven rated
high, twenty medium and four low — across nodemailer, undici, ip-address
and fast-uri in the application and its runtime image, and @grpc/grpc-js,
brace-expansion and http-cache-semantics under the Pulumi programs.
nodemailer moves to its 10.x line, which is the only one carrying the fixes;
nothing about how mail is configured changes. The application's Node base image
moves to the current 24-alpine build, and the frontend's nginx from the 1.29
line, which stopped receiving builds in May, to the maintained 1.30 stable line.
Routine minor and patch updates come with them, among them the MCP SDK, Better
Auth, Hono, zod and the Pulumi SDKs.
Selecting staged transactions no longer pushes the filters down the page,
and the bar that appears instead holds together through a commit. Ticking
rows on Staged transactions used to grow the filter row into a second and third
line, moving the filters that made the selection out from under the pointer.
The selection now gets a bar of its own, the same one Transactions and
Templates use, and it keeps its shape whatever it is showing: a selection
across pages, Commit disabled with the reason beside it, a commit under way.
A button at work, anywhere in the app, now shows its spinner where its icon
was rather than growing to fit both, so a row of buttons no longer shifts on a
click. And Delete selected no longer looks busy during a commit — it showed a
spinner for the whole of every one, on the button that deletes rows.
No page scrolls sideways any more, at any width. The stylesheet's own note
describes a bar that "could neither shrink nor wrap" between 560 and 900px, and
the remedy for it reached one container and not its siblings: Budgets' view bar
and Reports' options bar overflowed, and so did the Categories add-a-category
row. Above 780px the sidebar takes 248px of the window, so the narrowest the
content ever gets is around 820px rather than on a phone — which is why a check
at phone and desktop widths had found none of it. The documents were 958px wide
inside a 900px viewport, 871 inside 820, 837 inside 820. Every route is now
measured at each breakpoint, at each breakpoint minus one, in the middle of each
gap and at 320px, so the next one of these fails in CI rather than on somebody's
laptop. WCAG 2.2 SC 1.4.10.
The Transactions heading on an account, a category or a payee no longer
squashes itself on a phone. Its two buttons kept their full width at 390px and
wrapped the sentence beside them to four lines in whatever column was left. The
same heading's Register button, a few inches below, had been rendering as two
lines reading "Show" and "register" at every width.
The activity log reads as sentences again. It said "Create User
Preferences", because the text is built lowercase on purpose and CSS was
capitalizing every word of it rather than the first.
An account you have put away is "archived" everywhere now, not sometimes
"closed". Reports said "(closed)" on every archived row, "the ones you have
closed" in its empty state and "before they closed" in the note above its
table — on the page you go to after archiving something on Accounts, looking
for the word you just used. A shared description said "an account you have
since closed" to the browser and to every connected agent at once.
Three row actions that finish and take their own row with them now say so.
Archiving or deleting a category, committing a staged row, and removing a
budget override all ran with nothing in between and left the keyboard at the
top of the document. Each now reports what it did, where focus lands.
An archived category said so nowhere on a phone. The badge that marks it
shares a cell with the kind and the count, and that cell is hidden below 560px —
correct for the other two, which the row's own subtitle repeats, and wrong for
this one, which nothing else says. On the one screen you reach by turning Show
archived on.
Archiving an account with no balance no longer loses the keyboard. It is the
only action on that page that runs with no dialog in between, so the menu
closed, the button went with the card, and focus fell to the top of the
document with nothing saying what had happened. It now says what it did, where
focus can reach it.
A delete the server turned down no longer blanks the list. Templates and
Recurring each folded every error into one value and then rendered nothing in
the list's place when it was set, so a refused delete took the whole list with
it.
The auth library no longer writes email addresses to the log. At the
default LOG_LEVEL=info, Better Auth logged Sign-up attempt for existing email: and the address for every sign-up that named an account already here,
through its own console calls and outside the gate every other line goes
through. Its lines go through that gate now, tagged [Better Auth], and every
address in them, or in anything passed beside them, reads [email address].
The line itself stays, because a run of them is what probing for accounts looks
like and the sign-up response is careful to hide it. A pre-existing defect
rather than anything this release introduced; a log alert matching the
library's own prefix, INFO [Better Auth]:, is the one thing that notices.
A failure inside a sign-in route no longer goes to the log whole. An error
that was not the auth library's own — a failed query, most often — fell through
to its router's last resort, which wrote # SERVER_ERROR: and the error to the
console, the query's bound parameters with it, and in an auth route those are
addresses and OAuth tokens. It now reaches the same error handler as every other
route, which logs Request failed: and the statement without its values and
answers with the usual INTERNAL_ERROR body rather than an empty 500.
A restore into PostgreSQL 15 or 16 no longer empties the ledger. From 17
on, pg_restore opens every restore with SET transaction_timeout = 0, which
an older server does not have, and simple-balance-restore found that out
after it had dropped the database — leaving it empty and the application
stopped, on a server version the single profile supports. The script now asks
the server's version before it touches anything and, for an older server,
renders the dump to SQL, takes that one statement out and loads the rest in a
single transaction. Proved against a real PostgreSQL 16 with the postgres:18
client. It also reads /etc/default/simple-balance now, so the application it
restarts keeps the Caddy overlay's TRUST_PROXY.
Two presses of a plan button no longer make two subscriptions. The
per-user lock was held across Stripe, but the new subscription was stored only
after it was let go, so a second press waiting on the lock — a reload while
Stripe was slow, a second tab — found no subscription and made another, and the
person could pay both. The subscription is stored before the lock is released,
and the second press resumes the first.
A payee's name never reaches Google. The payee view is addressed by the
payee's name, and every link on it forwarded the query string, the sidebar
included, so one click to the overview put a person's name and who they pay
into an ad request's page address. No ad slot mounts while the page's address,
or the one it was opened from, carries a payee name, and no link leaving that
view forwards it.
The PostgreSQL 16 to 18 procedure for compose.distributed.yml can be run as
written. Every command in it named no compose file, in a directory that holds
more than one, so the first failed and left an empty dump behind; the last
brought the old images back up. It names the file once and builds the release.
Turning AdSense on no longer stops a compose deployment from starting. No
compose shape passed PRIVACY_POLICY_URL to the application, and the server
refuses to start with the AdSense ids set and no policy — so following the
instructions crash-looped the API and the scheduler on single and the
split recipe alike, the Oracle and AWS machines included. Both pass it now.
tests/env-example.test.ts holds every compose file that runs the server to
exactly the names src/server reads, in both directions, which is the check
that would have caught it.
DIRECT_DATABASE_URL now reaches the containers, for the same reason and
through the same test. The split recipe never passed it, so a deployment behind
a pooler that set it got no bypass and no word about it. A pre-existing defect
rather than anything this release introduced.
Neither single-machine Pulumi program could launch a machine. Oracle caps
instance metadata at 32,000 bytes and the user data was 67 KB; EC2 caps it at
16,384 and it was 50 KB. Both now send it gzipped, with whole-line comments taken
out of the files it carries, and pulumi preview refuses a document over either
ceiling rather than letting the launch find out: about 9.8 KB on Oracle and
7.2 KB on AWS. The Oracle program also asked for a 20 GB data volume, under
Oracle's 50 GB minimum, and now asks for at least 50 — small is still inside
the free tier. And its default stack had no way in, so it now requires an SSH
key and admits port 22 from its own subnet for OCI's Bastion. A later
pulumi up leaves either machine alone; an upgrade or a setting is applied on
it.
Behind either ha Pulumi program, every visitor shared one sign-in
allowance. The API counts sign-in attempts per address and reads it from the
frontend's X-Forwarded-For, and on both clouds that held a proxy's address,
the same for everybody, so four wrong passwords from anywhere locked out the
rest, and no trusted range set on the frontend could have fixed it on either
cloud as the programs built them. On AWS the network load balancer's IP targets
pass nothing about who connected, so ingress-nginx saw the load balancer: the
program now turns
proxy protocol v2 on at both ends in the one ingress-nginx release, believes the
header only from the public subnets where the load balancer's nodes are — never
the pods' private subnets, from which any pod could otherwise have named an
address of its choosing — and moves the load balancer's health check to
/healthz on port 80, because AWS sends the header on health checks and the
controller's server on 10254 cannot parse it. The frontend then trusts the
VPC's range with recursion off. On GCP the load balancer appends its own address
after the visitor's, so the program trusts Google's two front-end ranges and the
Ingress's reserved address with recursion on, and names container-native load
balancing on the frontend's Service, since through instance groups the pod would
see a node instead. It costs an AWS stack that is already running one
interruption of seconds to about a minute at the next pulumi up, while the two
ends change over. The frontend's half needs a frontend image of 0.2.0 or later,
and the programs deploy 0.2.0, which carries it. With the chart's
NetworkPolicy off, as both programs leave it, a pod inside the cluster can
still reach the API directly, or on AWS the frontend, and name its own address;
that reach is not new, and turning the policy on closes it. None of this has run
on a real AWS or GCP account: it rests on the vendors' documentation,
ingress-nginx's source, Pulumi's mocks and stock nginx built into each chain.
The nightly backup could not dump a TLS database, and now checks it. The
application needs sslmode=no-verify for a managed PostgreSQL whose certificate
names a private authority, and pg_dump refuses that value. The backup and the
restore now hand libpq sslmode=require in its place and drop the parameter
libpq does not know. And a DATABASE_URL of
?sslmode=verify-full&sslrootcert=/var/lib/simple-balance/tls/db-ca.pem now
verifies the database for the application and the backups alike, where
verify-full had failed every backup because the postgres:18 client image
carries no CA bundle. The new compose.db-tls.yml overlay mounts that directory
read-only into the application at the same path; it is an overlay rather than a
line in compose.yml because a rootless Docker daemon cannot make a directory
under /var/lib, and the line stopped every such deployment starting, a URL
naming no file included. The cloud programs name it in COMPOSE_FILE, and first
boot makes the directory on the data volume. The backup and the restore mount
the named file into their client under verify-full and verify-ca, or the
machine's CA bundle — or SB_PG_CA_BUNDLE — under verify-full with no file,
and refuse verify-ca with no file, which libpq would check against any public
CA with no name check at all. Every other sslmode is handled exactly as
before. The restore's connection to the maintenance database now keeps the
URL's query whole, where it had replaced the last path anywhere in the string
and so mangled an sslrootcert path or a socket host, and a server version
that does not come back as a number stops the restore with nothing changed
rather than being compared as one.
The nightly backup no longer starts a deployment somebody stopped. Its unit
bound itself to the application's, so the 03:15 timer brought a stopped
deployment back up in order to back it up. It now requires the deployment to be
running already, and fails instead.
Trying the plan in test mode and then going live no longer strands the
tester. The rows test mode wrote named a customer and a subscription no live
key can read, so the tester kept Premium for nothing, could never subscribe for
real, and the sweep retried the dead rows forever. A customer Stripe reports
deleted, or missing once a live key has found both prices in live mode, is now
forgotten and a new one made at the next subscription; the sweep marks a
subscription Stripe has no record of canceled under the same condition. A "no
such customer" or "no such subscription" from a test key, or from a key for the
wrong account, drops and cancels nothing: to either one every live customer is
missing, and believing it would end every paying subscriber's plan. Nor can
either confirm that an account being deleted leaves nothing charging, so the
deletion is refused, with a message that says so rather than one promising a
retry. Never point a live deployment at test keys, then. Nothing is canceled,
but nobody with a live customer can subscribe, replace a card or delete their
account until the live keys are back, and the store stays open to anybody else
with Stripe's public test card. docs/billing-operations.md §Going live, or changing Stripe account has
the steps that make it immediate.
The sweep no longer stalls behind rows Stripe cannot answer about. A row
that failed kept its old synced_at, so it stayed at the head of the
oldest-first queue, and fifty of them were every row the sweep would ever read.
A failure now stamps the time the attempt began, so the row is tried again after
the same twelve hours as everything else.
A card saved while Stripe was failing is no longer forgotten. The
setup_intent.succeeded delivery was claimed before the calls that pin the card,
so one transient failure left it claimed and never retried. The card is pinned
first and the delivery claimed after, and a late delivery for a card that has
since been replaced is claimed and ignored rather than pinning the older card.
A second change of plan interval no longer fails with an error the person
cannot clear. A move that waits for the renewal is made on a subscription
schedule, and Stripe refuses a new one while another exists — a switch still
pending on a price this deployment has stopped selling, or one made in Stripe's
dashboard — so the second press answered 500. Any existing schedule is
released first now.
An upgrade whose charge the bank wants confirmed now asks for it. Moving from
monthly to annual charges the difference at once, and when that charge needed
3-D Secure the subscription went past_due with no form to confirm it in. The
upgrade's own answer now carries the open invoice's secret, and the tab confirms
it there.
Paying a failed renewal now keeps the card that paid it. Pay now and
Pay what is owed confirm the open invoice in the page, and the card typed
into that form was used once and thrown away, so the next renewal charged the
card that had already failed. A renewal's PaymentIntent carries no instruction
to save what pays it, which is the difference between it and a first payment;
it is now marked to keep the method before its secret is handed out, and Stripe
makes that card the subscription's default. It is marked only while the payment
is still to be made and only where nothing has been said about the method
already, so a first payment is left exactly as it was. The marking is best
effort: a failure is one log line and the payment goes ahead anyway, because
refusing to take somebody's money over not being able to remember their card is
the worse of the two outcomes. It needs the key's PaymentIntents Write grant.
What moves is the subscription's default payment method, which is what Stripe
bills; the customer's own default is left alone, and pinning that too would
mean either a route that takes a PaymentIntent id or a Stripe write on every
invoice.paid.
The plan tab now checks with Stripe whenever money is owed, and says what it
finds. It re-read Stripe only for an unfinished first payment, so after a
Pay now that worked, a failed renewal went on saying "Payment failed" until
the page was reloaded. Every state that owes money is re-read now, on the load
that draws the page. That read also carries the four things the tab had no way
to say: why the last attempt failed, in the sentence Stripe writes for a
cardholder; whether the bank is waiting for the payment to be confirmed, which
is the one case Stripe does not retry, so the alert points at Pay now
rather than promising a retry that will not come; when Stripe will next try,
in the cases where it will; and when an unfinished first payment lapses. A
first payment's decline therefore survives a reload, where before it lived
only in the form that showed it and vanished with it.
And it no longer says a period renews when nobody has paid for it. "renews
on" and "ending on" are printed only for a period that has been paid for. An
unfinished first payment says nothing has been charged yet and when it lapses;
a failed renewal names the day the fifteen-day grace ends, taken from the same
function that ends it, so the date on the screen is the date enforced; an
unpaid subscription says Stripe has stopped retrying and that the plan is not
in force until what is owed is paid.
Changing the payment method while a payment is owed now says what happened to
the payment. Saving a card that was itself declined closed the form as though
it had worked, leaving somebody on a page that said they owed money with
nothing to tell them the new card had not paid it either. The answer now tells
paid, declined — carrying Stripe's own sentence for the cardholder — and a
payment the bank wants confirmed apart, the last of which no saved card can do
off-session and which the tab answers by pointing at Pay now. A failure it
cannot classify while money is still owed is reported as a failure rather than
as nothing owed, that being the one answer that would be a lie. A replay of an
idempotency key stored before this change carries no outcome, and is read as
unknown rather than as success.
The payment form no longer offers a method the subscription cannot be billed
with. A USD subscription's card replacement offered Satispay, Kakao Pay and
Naver Pay, because the form inherited every method the Stripe account had
turned on. Changing a payment method now narrows that list to a card — which is
how Apple Pay and Google Pay arrive — and Link. It narrows rather than demands,
and the distinction is the whole of it: a list Stripe reads as a demand is
refused whole over a type the account has not activated, and Link is off until
somebody turns it on under Wallets and is not offered at all in India, so
demanding it would have taken the card replacement away from the past-due
subscriber whose way back it is. Starting a subscription names no methods at
all, for the same reason, so its invoices get what the Stripe account allows
for invoices, narrowed to the currency. That list is the operator's to keep to
cards and Link, and docs/billing-operations.md step 6 says so. A saved method
the subscription still refuses is answered with a sentence and changes nothing,
and the setup_intent.succeeded delivery for one is recorded and acknowledged
rather than failing every retry for three days. The method is pinned on the
subscription before the customer, so a refusal leaves the two agreeing rather
than disagreeing. None of this has been tried against a Stripe account with
Link turned off; it rests on Stripe's documented behavior for the two list
parameters and on what the sandbox's own intents resolved to.
And the tab stops calling every payment method a card. Replace card is
Change payment method, Save this card is Save this payment method,
the panel headed Your card is Payment method, and the renewal terms say
a plan is charged to your payment method. Link can be funded from a bank
account, so the old wording was wrong about what was being saved.
A cancellation set in Stripe's dashboard now shows on the plan tab, and holds
a change of plan. Stripe records "stop at the end of the period" two ways, as
a flag and as a date, and this deployment read only the flag — so a
cancellation set by date was stored as renewing, and the tab offered no way to
undo something it did not know about. Both spellings are now one question,
cancellationPending, and the "ending" line and Keep my plan the tab
already had apply unchanged. A date falling inside the current period needs
nothing stored, because Stripe moves the period end onto it. One dated further
out is the case the flag is false for — that period really does renew — so the
day itself is kept, in billing_subscription.cancel_at, which
0025_subscription_cancel_at.sql adds. While a cancellation is pending, asking
for the other interval is refused with "Your plan is set to end. Press Keep my
plan before changing it.", and Stripe is sent nothing. Before, Monthly
quietly turned renewal back on, because the schedule it creates clears the
cancellation, and Annual charged the difference for a year of a plan that
was set to stop. Paying what is owed and
repeating the plan already held are not changes of interval and still work. The
status line goes on asking the flag alone, because the day it prints beside
"renews" is the renewal's rather than the ending's; the note saying what ending
the plan would freeze is where a further-out day is named, which is the one
place it is a date somebody can act on.
Two overlapping presses of Annual no longer move the renewal date twice.
The upgrade was made at Stripe and stored only after the lock was released —
the same shape as the double subscription above — so a second press waiting on
the lock read a monthly subscription and upgraded again, anchoring the billing
cycle a second time. The upgraded subscription is stored under the lock now,
and the second press reads it and finds nothing to do, or, where the charge is
still owed, the payment to finish.
The plan tab's buttons and the note under them now follow the server's rule
rather than a copy of it. Each priced button is disabled exactly where the
shared rule says the press would do nothing, and says which reason it is: the
plan already held, a switch already set and the date it happens, or a plan set
to end, in the server's own words. Letting a scheduled switch go has its own
Stay on the annual plan button, where it used to be done by pressing the
priced button of the plan already held — a button that is otherwise disabled,
so on a deployment that had stopped selling there was no way to do it at all.
That button carries the renewal terms, because letting a switch go decides what
the next renewal charges. The note under the buttons is built from the same
rule, so a subscriber whose renewal has failed is no longer told that moving to
annual "takes effect now" when the move is scheduled, and a plan that is ending
gets no renewal sentence at all.
While a payment form is open it is the only way to pay. The panel went on
drawing its own Pay now, Finish your payment or Pay what is owed
beside the form — a second primary button with the same name, for the same
payment, pressable while the form was confirming. Those are hidden while a
payment form is open. A form that only saves a payment method hides nothing,
because it is not another way to pay.
And every result on that tab is now a sentence that takes focus. Every
button there disables itself while it works, and a disabled element cannot hold
focus, so the browser let go of it as the request started and the answer
arrived with focus on <body>: a keyboard user began again from the top of the
page. A payment, a saved payment method, a refusal, and a change that needs no
payment — canceling, keeping, scheduling a switch, letting one go, upgrading —
each say what happened and take focus, worded after the plan has been read
again so a date in the sentence is the new one. A form that opens from a press
takes focus as well, since the button that opened it has gone or let go.
Neither moves focus on a page load: coming back from a bank's page is a fresh
document, and its sentence is left where a reader finds it.
Coming back from a bank's or a wallet's confirmation page now finishes the
job. The address Stripe was told to return to was the tab's whole address, so
its parameters piled up a set at a time, and a payment method saved through a
redirect was never confirmed — the tab drew the plan it already had and said
nothing about what had just happened. The return is to the tab's own path now;
the five parameters Stripe adds are taken off the address on arrival, the
client secret among them, and anything else on it is kept; a method that saved
is confirmed through the same route the in-page form uses, once, keyed so a
reload cannot confirm it twice; and each of the six outcomes gets a sentence of
its own.
A stale "Please select a payment method" no longer sits above a form that is
complete. Stripe's element draws and clears its own validation message, and
the tab was copying it into an alert of its own that nothing cleared. The
tab's alert is cleared when the form reports itself complete, and only then, so
a decline in the middle of filling the form is not erased; a validation error
is left to the element, beside the field it is about.
A refused change of plan now re-reads the plan. The tab said the refusal
and went on offering exactly what had just been refused. Every refusal from the
four billing mutations re-reads the plan and the session, without closing a
form somebody is part-way through.
The plan tab says what a plan's limit is doing to your accounts. It gave
the places in use against the limit and said nothing at all about the accounts
past it: how many are frozen, what being frozen means, or where the one-time
choice is made. It now says how many are frozen, that they stay readable and
counted in every total, and links to that choice on the Accounts page while it
is still open. And it now says what ending Premium would freeze, beside
Cancel at period end before the press and again while a cancellation is
pending, along with whether the one-time choice would be yours to make or
already made. The figure is the server's, because the page cannot work it out:
what freezes is the accounts nobody chose, which is not the same as every
account past the third, and an operator's grant outlasting the period end
freezes nothing at all. freezeOnPlanEnd answers both halves at once, by
resolving the entitlement that would be in force afterward with the
subscriptions dropped — so somebody who chose three of five before upgrading is
told the same two freeze with nothing left to choose, somebody who then
archived one of the three is told two and not one, and a grant that outlives
the period gives no number rather than a zero. It rides on the plan status as
accountsFrozenOnFree and activeChoicePendingOnFree, and a bundle served by
a container from before those fields reads their absence as nothing to say
rather than as a zero, which is what keeps the tab working through the upgrade.
The one-time choice of active accounts can be saved with the accounts it
starts on. The Accounts page's Choose which accounts stay usable panel
compared the selection against the accounts that are not frozen, which is also
what it starts with ticked — so the commonest answer of all, keep the three
already in use, was read as no change and Save stayed gray under "Nothing
to save yet." Only an agent could close the choice that way. The comparison is
against what is stored now, which while the choice is open is every account, so
any selection inside the limit can be saved. Once the choice is made the two
are the same again, so it is still a choice made once.
A frozen account now says why it will not let you do something, before you
try. Its card's Edit, Archive and Delete were disabled with no
reason given, and in the transaction list Edit, Delete and Restore
were not disabled at all: they opened a form or a confirmation, and the server
refused what came back as one message at the top of the list that named no
entry. Entries on a frozen account now carry a Frozen badge in the Account
column, their three buttons are disabled, and the reason names the account, so
a transfer says which of its two sides is frozen. Delete selected and Edit
selected are disabled the same way whenever the selection holds such an entry
the page has seen — on this page, on a page it has left, taken in by "Select
all matching", or carried off the page by a reorder — because the server
refuses those whole rather than skipping rows, and unticking or excluding the
entry frees them again. A filtered selection's rows the page has never shown
are the part the browser cannot see; those still meet the server's refusal,
which now names the entry it was about. The account card's three menu items
carry the reason too, written once under the menu because one sentence covers
all three.
And a disabled row icon or menu item now looks disabled. Those two families
paint their own color, background and cursor, so the rendering a browser gives
a disabled control never showed: a frozen account's Edit icon was
pixel-identical to a live one, down to the hover fill, which is how the badge
and the description came to be the only things saying so. Both now take
.button:disabled's own dim and cursor: not-allowed, read from that rule
rather than chosen again, and both hover rules are narrowed to live controls
rather than answered by a second rule repainting what the first painted — two
rules fighting would leave both spellings live for whoever adds the next hover
state. The pagination controls' dimmer 0.45 is left where it is and is not the
number to copy. tests/theme-tokens.test.ts holds both families to the house
dim and holds every :hover on a family that ships disabled to
:not(:disabled), and tests/field-contract.test.tsx now reaches a plain
<button> inside a RowMenu or behind a spread, which is where a census of
Buttons could not see that these three said nothing.
An agent refused for a frozen account is now told something it can act on.
The refusal was written for somebody looking at a browser: it names the account
and offers the two ways out of it, make the account active or upgrade, and an
MCP token can take neither — buying a plan is one of the three things
reachable only from a session, and the choice is made once, so resending a
list does not take a freeze back. A connection over MCP now gets a second
sentence written for it, naming whoami for the plan and its ceiling,
list_accounts for which accounts are frozen, and an archive or a delete
freeing a place, or the person upgrading, as the only things that lift one.
The server also says it once, in the instructions every connection reads,
because one guard refuses writes from ten places across four services and only
the tool that is about the choice could carry the sentence in its own
description — so an agent met a rule nothing had warned it of, one refusal at
a time. Nothing an agent sends changes, and the refusal's code, status and
person-facing sentence are what they were.
Deleting an account no longer promises to cancel a paid plan nobody had.
The note said "Your paid plan is canceled", which was false for a first payment
that never went through: there is a subscription, deleting the Stripe customer
does cancel it, and nobody ever paid for anything. It says the subscription is
canceled. It also says what it had never said: that nothing is refunded, that
time left on what was paid for is lost, and to talk to whoever runs the server
before deleting if a refund is owed. The last dialog, which is the thing
actually confirmed, says the subscription ends now and nothing is refunded.
The browser app no longer trips its own content security policy on every page
load. Zod asks once, as it loads, whether it may compile a faster validator,
and it asks by constructing an empty function — which is eval, which this
app's policy has refused since 0.1.0. Zod catches the refusal and uses its
interpreted parser, so nothing was ever broken, but the browser reports the
violation before Zod can catch it. The question is turned off before any schema
is built, which needs its own module imported first, because imports are
hoisted and the shared schemas are built while they load. It matters most in
the rehearsal SB_CSP_REPORT_ONLY is for, where a log meant to show which of
Stripe's hosts a live account reaches instead filled with a report about eval
that invites exactly the wrong fix. Under that rehearsal policy, where eval
is allowed, this also moves Zod to the interpreted parser it was already on
everywhere else; the two agree, and nothing this app parses is large enough to
notice.
An ad unit Google has nothing to fill now collapses. An unfilled unit kept
the 280px band the slot reserves, so a page shown no advertisement ended in a
blank stripe. Both the unit and the slot around it are hidden once Google marks
it unfilled, which is the rule Google documents for exactly this.
An enabled button no longer sits below a disabled one that says why. A
button's reason is a line under it, so the wrapper showing one is the tallest
thing in its row, and a row that centers its items dropped every neighbor half
a reason lower — 8.5px, and 15px with a two-line reason on a phone. The plan
tab showed it on every visit, one of its two plan buttons always being disabled
with a reason. A form's action row and a modal's footer line up on the tops
while a reason is showing, and a line of text in such a row is given a button's
height so it keeps the midline it had.
The forecast read every tenant's budget plans, not just yours.
/api/v1/forecast joined a budget plan to its category on the category id alone,
with no owner, while every equivalent query carried one on both sides. It was
unreachable on a single database — two people cannot hold the same category id
while the key is the id alone — and became reachable the moment the ledger was
distributed, which is how it was found. Scoping the join fixed a tenancy hole and
a cluster failure in the same edit.
Waiting for PostgreSQL waited for the wrong server. The official image runs a
temporary server while it executes its initdb scripts, and that one answers on
the unix socket alone. Five readiness checks in this repository asked over the
socket, so each could report a database ready moments before it was stopped and
replaced — and whatever was waiting connected to FATAL: the database system is shutting down. All of them now ask over TCP, and a test walks the repository for
any that does not.
The single profile's backups ran against a database it no longer has. When
that profile stopped bundling PostgreSQL, the scripts around it went on assuming
one: every nightly dump would have failed, and so would the restore somebody
reached for at three in the morning. Both now work out how to reach the database
by reading the deployment's own service list — inside it where there is one, over
the network where there is not — so one script serves a machine with the database
beside it and a machine without, and no setting can disagree with the compose
file about which database is being backed up. That branch is what let the
single profile grow a database of its own without a line changing: the
database is on the other machine, so the application node's service list still
holds no postgres and it still dumps over the network.
The development database and a single deployment no longer fight over the
same containers. Compose takes a project name from the directory when a file
does not give one, which made compose.dev.yml claim simple-balance — the
name deploy/compose/single/compose.yml declares for itself. Bringing the
profile up on a development machine silently replaced the development database
with it, bind mount and all. The development file now names itself
simple-balance-dev, so the two coexist.
A promise this project has made since 0.1.0 is now a test. "This image
makes no outbound connection nobody configured" carried "not checked
mechanically" for four releases, because an allow-list of network calls did not
exist. Stripe arriving as a library rather than a URL is what changed it: the
way that was made safe — one module imports the vendor, and it turns the SDK's
own telemetry off — is exactly what makes the promise answerable by reading one
file. A test now holds all three. A new vendor library is still a reviewer's
job, and the guide says so rather than implying otherwise.
A check meant to keep personal information out of /metrics could not fail.
No metric label carries somebody's identity, because whoever can reach that
endpoint is not the person whose ledger it counts. The check holding that
promise read the label names off each metric's declaration — and
getMetricsAsJSON, which is what it asked, hands back a metric's help, name,
type, values and aggregator and no declared names at all, so it read an empty
list for every metric and passed on anything, a counter labeled with an email
address included. No metric has ever carried one and none ever shipped: the rule
held by review, which is the state a check is supposed to replace. It is now
read where a scrape reads it, off the published values, which is also the only
side the registry's own component label ever appears on. Nothing about what
/metrics serves changes, and it is still absent unless METRICS_ENABLED asks
for it.
IDEMPOTENCY_RETENTION_HOURS now reaches the containers. The compose recipe
has documented it since 0.1.6 and never passed it, so an operator who set it got
no retention sweep and no indication why. Nothing tested that the recipe
delivers what its own example file promises; that is now a test, which is the
half that keeps it fixed.
Deleting an account no longer leaves your address behind. The cleanup of
pending verification rows matched only the shape a password reset writes, so an
abandoned account-link or authorization flow left a row holding the user id —
and, for a link, the email address — after the account was gone. It matches all
three shapes now. Both are pre-existing defects rather than anything this
release introduced.
Two lists told you the wrong thing about your own data. Payees showed one
message whether the ledger was empty or a search had simply matched nothing, and
its two halves disagreed with each other: the heading said "in this view" while
the text underneath told you payees appear once you commit a transaction — the
wrong next step for somebody who has committed hundreds and mistyped a name.
Accounts told anyone whose accounts were all archived that they had none yet,
and offered to create one, rather than pointing at the archived ones already
there. Each list now separates "nothing yet" from "nothing matches this view",
because the way out of the two is the opposite. Both are pre-existing defects
rather than anything this release introduced.
The Activity page printed some operations as code. The budget and
category-group services record an operation as entity.camelCaseVerb, and the
page printed that whole, so creating a budget plan read as budgetPlan.create
followed by the entity's own name. It reads "Create Budget Plan" now. The stored
operation is unchanged, and so is what the MCP returns. A pre-existing defect
rather than anything this release introduced.
Re-running the product kit no longer posts its history twice. Each seeded
entry was posted under a key that carried its date, and an entry still ahead of
today is clamped to today, so a re-run on a later day posted those entries
again, and once the month turned the whole history went in twice — every figure
in the marketing screenshots too high, with nothing on screen to say so. The key
is now the entry's calendar month and its place in the seed
(scripts/product-kit/entry-key.mjs), so a re-run against the same database
replays within the month, and once the month turns adds that month's entries
rather than the whole history a second time. A database seeded by an earlier
build shares none of the new keys, so drop it once.