Skip to content

πŸ—οΈ PUT-1712: emit workspace billing events for the payment integration - #3733

Open
jfcastro92 wants to merge 2 commits into
juancastro/put-1710-31-register-the-team-policy-and-subscription-resolverfrom
juancastro/put-1712-33-emit-on-demand-billing-events-for-the-payment-integration
Open

πŸ—οΈ PUT-1712: emit workspace billing events for the payment integration#3733
jfcastro92 wants to merge 2 commits into
juancastro/put-1710-31-register-the-team-policy-and-subscription-resolverfrom
juancastro/put-1712-33-emit-on-demand-billing-events-for-the-payment-integration

Conversation

@jfcastro92

@jfcastro92 jfcastro92 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Replaces #3722, which was based on the now-closed #3721. Stack: #3704 β†’ #3705 β†’ #3708 β†’ #3709 β†’ #3710 β†’ #3712 β†’ #3713 β†’ #3714 β†’ #3720 β†’ this.

PUT-1712. The trigger for billing, and after the OSS/prod split this is the whole of what OSS owes it.

The charge lives outside this repo β€” the same place that cancels Stripe subscriptions off user.delete. That event is the precedent: emit a typed event, let an extension do the external side effect.

The events

Event Billing meaning
team.account.created Start charging the owner for a seat
team.account.disabled Stop it; the account still holds its bytes
team.account.enabled Resume it
team.account.deleted Stop it permanently
team.deleted Stop every seat charge for that workspace

Each carries the workspace uid, the affected account, and the owner's stripe_customer_id.

⚠ team.account.deleted has to be captured before the delete

jct_user_group.user_id is ON DELETE CASCADE (0015_group.sql). By the time a listener on user.delete runs, the membership row is gone and nothing can say which workspace paid for the account β€” the payload is the only surviving evidence.

So cascadeDelete captures the seat identity before teardown and emits after, exactly as it already does for uuid / stripe_customer_id and for the same reason. A test asserts the membership is unreadable afterwards, so the constraint that forces this design is pinned rather than assumed.

⚠ stripe_customer_id does not exist on sqlite

It ships in mysql_mig_1 and postgres_mig_1 but no sqlite migration β€” cascadeDelete already wraps its read in a try/catch for this. getStripeCustomerId does the same and degrades to null, so a dev or self-hosted install emits usable events rather than throwing inside a lifecycle operation. The postgres suite covers the branch that returns a real value.

Deleting a workspace emits per seat

One team.account.disabled per seat plus the workspace marker, not one bulk event: the accounts persist, suspended, holding their files and their usernames.

Deleting a workspace is not a way to stop paying for the accounts in it. Deleting the accounts is. PUT-1740's confirmation dialog has to say so, because the opposite is the natural assumption.

getOrgSeat deliberately admits soft-deleted workspaces for the same reason β€” their accounts still exist, so the charge is still running.


Verification

$ npx vitest run --config src/backend/vitest.config.ts src/backend/services/team/
      Tests  7 passed (7)   [TeamEvents.test.ts]

$ npm run test:backend
 Test Files  284 passed | 24 skipped (308)
      Tests  7334 passed | 26 skipped (7360)

$ npm run typecheck
Type check passed β€” no new errors (32 known, baselined).
Per-site falsification

Each emit disabled individually:

baseline                       0 failed | 11 passed
team.account.created           1 failed | 10 passed
team.account.disabled          1 failed | 10 passed
team.account.enabled           1 failed | 10 passed
team.deleted                   1 failed | 10 passed
team.account.deleted           1 failed | 10 passed
pre-delete seat capture        1 failed | 10 passed
held_bytes on the payload      2 failed |  9 passed
restored                       0 failed | 11 passed

Every site fails exactly one test.

What changed since the original #3722

The held-bytes report and TeamBillingService are gone, not moved. Under the rescoped model a disabled seat is an ordinary suspended account holding files β€” not a team-specific case β€” so there is nothing team-shaped to measure. Whether the owner is charged for held data is now an open pricing question; prod can compute SUM(size) itself if we decide we want it.

held_bytes still rides on the disable and enable payloads, so prod has the figure at the transition without querying back.

See TEAMS-BILLING-SPLIT.md and PUT-1765.


Closes PUT-1712. PUT-1747 is superseded.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
πŸ”΅ Lines 93.81%
⬇️ -0.02%
28641 / 30529
πŸ”΅ Statements 91.84%
⬇️ -0.01%
31088 / 33849
πŸ”΅ Functions 90.36%
⬇️ -0.01%
5225 / 5782
πŸ”΅ Branches 80.75%
⬇️ -0.01%
20619 / 25532
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/clients/event/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/services/team/TeamService.ts 82.23%
⬆️ +0.53%
70.47%
⬆️ +3.11%
93.93%
⬆️ +1.08%
83.79%
⬇️ -0.31%
80-81, 101-102, 116, 170-172, 175-177, 186-219, 247-250, 258, 264, 301-304, 356-358, 412, 473-476, 522-524, 544, 559
src/backend/services/user/UserAccountService.ts 93.33%
⬆️ +0.31%
83.33%
🟰 ±0%
100%
🟰 ±0%
97.22%
⬆️ +0.17%
71, 173, 176
src/backend/stores/fs/FSEntryStore.ts 93.91%
⬆️ +0.01%
85.17%
⬇️ -0.11%
100%
🟰 ±0%
94.36%
⬆️ +0.01%
115, 120, 125, 129, 134, 141, 194, 282, 424-427, 437, 446, 453, 526, 557, 594, 645-647, 667, 681-683, 686-690, 741-745, 748-752, 767-771, 774-778, 817-819, 866, 876-894, 897-901, 921, 982, 984, 1039, 1049, 1106, 1136, 1205, 1320, 1379-1383, 1393-1397, 1596, 1709-1713, 1856, 1864-1868, 1945, 2000-2004, 2129-2131, 2139-2141, 2153, 2243, 2368-2370, 2401-2402, 2836-2841, 3041, 3096, 3114
src/backend/stores/team/TeamStore.ts 94.33%
⬇️ -1.74%
84.41%
⬇️ -3.26%
92.3%
⬇️ -3.53%
95%
⬇️ -1.87%
241, 267, 384-399
Generated in workflow #1591 for commit fb97d72 by the Vitest Coverage Report Action

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1712-33-emit-on-demand-billing-events-for-the-payment-integration branch from 86d5fb1 to d149568 Compare September 3, 2026 18:56
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1712-33-emit-on-demand-billing-events-for-the-payment-integration branch from d149568 to d3c1aa9 Compare September 3, 2026 19:20
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1712-33-emit-on-demand-billing-events-for-the-payment-integration branch from d3c1aa9 to a0a76f3 Compare September 3, 2026 19:45
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1712-33-emit-on-demand-billing-events-for-the-payment-integration branch from a0a76f3 to c0dd00b Compare September 3, 2026 20:02
@jfcastro92

jfcastro92 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Warning

Superseded β€” the payloads below are out of date.

They show stripe_customer_id, which this PR no longer emits. It was dropped after review: the payer's row is alive at emit time, so a value captured here would be stale, and two seats provisioned concurrently would each see null and each create their own customer. A consumer resolves payment identity when it acts.

Kept for history. See the re-validation comment below for the current shape.


@jfcastro92
jfcastro92 force-pushed the juancastro/put-1712-33-emit-on-demand-billing-events-for-the-payment-integration branch from c0dd00b to b039058 Compare September 3, 2026 20:32
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1712-33-emit-on-demand-billing-events-for-the-payment-integration branch from b039058 to 2ae83fd Compare September 3, 2026 23:55
@jfcastro92
jfcastro92 requested a review from Salazareo September 4, 2026 00:18
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1712-33-emit-on-demand-billing-events-for-the-payment-integration branch from 2ae83fd to 3bb15f4 Compare September 4, 2026 01:02
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Local validation (re-run after the payload change)

Replaces the comment above, which showed a stripe_customer_id this PR no longer emits.

Engine: mysql 8 Β· teams_enabled: true Β· observed through a real consumer, not a log probe: the prod extension from HeyPuter/heyputer#1172, loaded into the local server with only its import alias adjusted. So this is the shape an actual listener receives.

What a consumer now gets

[team-seat-billing] would start charging {
  teamUid: 'f8c785d4-35e3-4e10-947c-3c4a3c2b5d6f',
  userUuid: '65636cde-7b3e-4de0-aab4-06f9aa01a6a9',
  ownerUserId: 96
}
[team-seat-billing] would pause charging  { …, heldBytes: 0 }
[team-seat-billing] would resume charging { …, heldBytes: 0 }
[team-seat-billing] workspace closed {
  team_uid: 'f8c785d4-…', owner_user_id: 96, account_count: 1
}
stripe mentions across every captured payload: 0

owner_user_id is what identifies the payer; resolving them to a payment identity is the consumer's job, at the moment it acts.

Why the field went

Not tidiness β€” it was a hazard. The value was a snapshot taken at emit time, and the payer's row is alive throughout (unlike user.delete, whose comment records the opposite: "the row is already gone by emit time, so identifiers ride the payload").

Concretely: provision two seats in quick succession for an owner who has never paid. Both events capture null. A consumer handling them concurrently sees null twice and creates two customers for one owner, with the second seat's charge landing on a customer the first flow never learned about. Keeping the field did not prevent that β€” get-or-create has to be atomic in the consumer regardless β€” so it bought nothing and invited the race.

TeamStore.getStripeCustomerId went with it, which takes the last payment-vendor method out of the OSS teams path.

Everything else is unchanged

The five events still fire at the same transitions, held_bytes is still computed at emit time, and workspace closure still fans out one pause per seat before team.deleted β€” verified in this run.

Full suite 7733 passed | 26 skipped, typecheck clean.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1712-33-emit-on-demand-billing-events-for-the-payment-integration branch from 3bb15f4 to 7359db3 Compare September 4, 2026 03:23
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (6d7be8bd6), which landed the KV share-grant series (#3690–#3693).

One collision worth naming: those commits took migration 0080 / mysql_mig_35 / postgres_mig_24 β€” the exact three numbers this stack's temp_password_expires_at migration held. Renumbered to 0081 / mysql_mig_36 / postgres_mig_25, registry entry [76, ['0081_temp-password-expiry.sql']], and CURRENT_SCHEMA_VERSION is now 77. Main's three files are byte-identical to what it merged; only ours moved.

Verified per commit rather than at the tip β€” each of the 9 typechecks on its own, so no intermediate commit is broken. Full suite 7883 passed | 26 skipped.

@Salazareo Salazareo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good but I think we should cache some of these store queries

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1712-33-emit-on-demand-billing-events-for-the-payment-integration branch from 7359db3 to bb5e32b Compare September 4, 2026 13:35
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Terminology: "workspace" is now "team", everywhere

The feature said puter.teams in the namespace and "workspace" in every string, comment and error code. That divergence was deliberate at design time; it is now settled the other way, and the whole stack says team.

Applied to every commit rather than added on top, so each PR shows its own code already named correctly and no intermediate commit references a name a later one changes. All 13 commits still typecheck individually.

The contract-bearing renames

These were free to change now and expensive after release, which is why they went in this pass:

Before After
not_the_workspace_owner not_the_team_owner
workspace_limit_reached team_limit_reached
workspace_deleted team_deleted
max_workspaces_per_user max_teams_per_user
max_seats_per_workspace max_seats_per_team
disabled_by_workspace disabled_by_team
createWorkspace / updateWorkspace / deleteWorkspace createTeam / updateTeam / deleteTeam

Plus twoWorkspaces.ts β†’ twoTeams.ts, shareWorkspaces.js β†’ shareTeams.js, the teams-workspace-* CSS classes, and the team_workspace_deleted email template, which the mechanical pass turned into the stutter team_team_deleted and is now team_closed.

Scoped, and checked

"workspace" means other things in this repo. These were deliberately left alone, and verified untouched:

  • kvShares.ts and the KV integration tests, where workspace:abc: is a key prefix in an example, not a team
  • Events.md and KV.md, which use it the same way
  • UIPermissionDialog tests, same
  • package.json / webpack/BaseConfig.cjs / .gitignore, where it is npm workspaces

Audited three ways: every changed line mentions team or workspace (no collateral edits), no stutters or mangled words (team_team, teamspace, …), and both old config keys are gone with the new ones consistent across all 12 sites that read them.

One thing not renamed

The git branch names still say workspace (put-1740-1762-workspace-gui). Renaming a branch changes a PR's head ref and closes the PR, so they stay as they are β€” they are refs, not product surface.

@jfcastro92

jfcastro92 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

looks good but I think we should cache some of these store queries

Done, in #3735 β€” and it now covers more than my first reply said, including the two I had originally argued for leaving alone.

Cached, all on a 60s TTL with explicit invalidation:

Read Why it matters
getByUid every team route resolves the team through it
getMembership / isMember the authorization check on every team route
listMemberIdsByGroupId share-notification fan-out
getOrgSeat this PR's path β€” captureSeatForBilling on every account deletion

I initially said I would leave isMember and getByUid uncached because they gate authorization and a stale entry means a removed member keeps access. That reasoning was about the risk, not about whether it could be done safely β€” so rather than skip the hottest two reads, the invalidation is now exhaustive and each path has its own falsified test.

Every path that can change either value busts it:

  • addMember / removeMember β†’ the membership pair
  • update β†’ the team row (name, handle, directory_enabled all live on it)
  • softDelete β†’ the row and every membership pair over it, read while the team is still live and bounded by the fan-out cap
  • cascadeDelete β†’ the pair, because jct_user_group.user_id is ON DELETE CASCADE and the membership changes without passing through the store at all

That last one is the reason this needed care: a TTL alone leaves a deleted account passing isMember for up to a minute. An existing test caught it the moment the cache went in.

Falsified individually β€” each bust removed on its own, and only its own case fails:

Broke Failed
pair bust on add/remove 1
row + pair bust on softDelete 1
row bust on update 3
pair bust on cascade 1

Still not cached, deliberately: getHeldBytes, a SUM over fsentries. It is expensive, but it is read at emit time precisely to be current β€” caching it would bill a stale figure.

Backend 7897 passed | 26 skipped, typecheck clean, all 13 commits typecheck individually.

`addMember` refuses to turn an account that already has a password into a
team seat. No service path did this β€” `provisionAccount` always creates β€”
but the store permitted it, and the design rules out existing accounts joining
a team.

Provisioning passes the guard because it admits the account before setting its
temporary password.

There is no bypass parameter. Both HTTP suites now provision a real seat and
authenticate as it, using the same token-minting the harness uses for
`POST /login`. That surfaced something worth knowing: an unactivated seat
cannot call the API at all. Provisioning leaves `requires_email_confirmation`
set and `requireVerified` rejects it, so the suites activate the seat first β€”
which is the state a member is actually in when making requests.

`listMembers` and `getMembership` also return `u.uuid`, which the billing
events need in order to name the account without a second lookup.
The charge lives outside this repo, the way the marketplace extension cancels
Stripe subscriptions off `user.delete`. This is the trigger, and it is the
whole of what OSS owes billing.

  team.account.created    a seat exists and can be used
  team.account.disabled   it stopped, and still holds its bytes
  team.account.enabled    it resumed
  team.account.deleted    it is gone
  team.deleted            the team is gone; its accounts are not

Each carries the team uid, the affected account, and the owner's
`stripe_customer_id`. That column ships in the mysql and postgres schemas but
not sqlite, so the read is guarded and degrades to null, as `cascadeDelete`
already does.

Deleting a team emits one `team.account.disabled` per seat plus the
team event, rather than one bulk event: the accounts persist, suspended,
holding their files and their usernames. Deleting a team is not a way to
stop paying for the accounts in it.

`team.account.deleted` is captured before the row is deleted.
`jct_user_group.user_id` is ON DELETE CASCADE, so by the time a listener on
`user.delete` runs, nothing can say which team paid for the account.

`getOrgSeat` deliberately admits soft-deleted teams: their accounts still
exist, so the charge is still running.

Closes PUT-1712.
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1712-33-emit-on-demand-billing-events-for-the-payment-integration branch from bb5e32b to fb97d72 Compare September 4, 2026 19:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants