mibudge runs with closed registration: there is no public sign-up page. New users get accounts exactly two ways, both invitation-based:
| Flow | Who can invite | What accepting grants | Where it is managed |
|---|---|---|---|
| Co-owner invitation | Any owner of a bank account | Co-ownership of that account (added to owners M2M) |
SPA / REST API (/api/v1/bank-accounts/{id}/invite/) |
| Admin user invitation | Staff, via the Django admin | An active mibudge account (no account access yet) | Django admin only |
The co-owner flow is the everyday path: a family member shares a joint checking account with their partner, who may not have a mibudge account yet. The admin flow exists so an operator can onboard a user who isn’t being invited to any particular account.
Both flows share one set of machinery – exceptions, rate limiting, and
email dispatch live in
app/common/invitation.py; new-user
onboarding helpers live in
app/users/onboarding.py. The
flow-specific service layers are
app/moneypools/service/invitation.py
(co-owner) and app/users/invitation.py
(admin).
An invitation is a database row (BankAccountInvitation in
app/moneypools/models.py,
UserInvitation in app/users/models.py)
that moves through a small state machine:
┌──────────► accepted
│
(created) pending ┼──────────► declined (co-owner flow only)
│
├──────────► cancelled (inviter/admin changed their mind)
│
└──────────► expired (token TTL elapsed, 7 days)
All four non-pending states are terminal. Rows are never deleted – they are the audit trail, and they feed the rate-limiting window described below.
Expiry is applied lazily: an invitation past its expires_at is still
pending in the database until something touches it, at which point
_validate_pending() (in each service module) flips it to expired
before raising TokenExpiredError.
Each invitation carries a 64-character URL-safe token generated by
generate_token() in app/common/tokens.py
(secrets.token_urlsafe(48) – 384 bits of entropy). The acceptance
URL embeds this token, and possession of the token is the only
authentication required to view, accept, or decline the invitation.
This is deliberate: the invitee usually has no mibudge credentials yet, so the acceptance flow cannot sit behind a login. The token’s entropy makes guessing infeasible, the 7-day expiry bounds its useful life, and the terminal states make it single-use.
The links in invitation emails point at plain Django template views –
/invitations/account/{token}/ for co-owner invitations
(app/moneypools/invitation_views.py)
and /invitations/user/{token}/ for admin invitations
(app/users/invitation_views.py).
They are server-rendered rather than SPA routes so that a brand-new,
unauthenticated invitee never has to pass through the JWT auth
bootstrap just to see the invitation.
For native-app clients, DRF endpoints mirror the same operations (see the endpoint table below). Both paths call the same service functions, so the business logic exists in exactly one place.
If the invitee has no mibudge account, get_or_create_inactive_user()
(app/users/onboarding.py) creates a
placeholder: is_active=False with an unusable password. This
placeholder cannot log in and does nothing until the invitation is
accepted.
On acceptance, the service activates the account and calls
trigger_password_reset(), which sends an allauth password-reset email
so the invitee sets their first password through the normal
one-time-link flow. Acceptance never issues a session or a password
directly – proving control of the email inbox (twice: the invitation
link, then the reset link) is the entire trust chain.
Until the invitee completes that reset, the account reports
has_usable_password == False on GET /api/v1/users/me/, and the
change-password / change-email endpoints refuse to operate (see
email-change.md).
Service layer:
app/moneypools/service/invitation.py.
Management (authenticated, account owners only):
| Endpoint | Action |
|---|---|
POST /api/v1/bank-accounts/{id}/invite/ |
Send an invitation (invitee_email in the body) |
GET /api/v1/bank-accounts/{id}/invitations/ |
List pending invitations for the account |
POST /api/v1/bank-accounts/{id}/invitations/{token}/cancel/ |
Cancel a pending invitation (sender only) |
GET /api/v1/users/me/invitations/ |
List the current user’s outgoing pending invitations |
Public (no auth – the token is the credential):
| Endpoint | Action |
|---|---|
GET /api/v1/invitations/{token}/ |
Invitation details for rendering an acceptance UI |
POST /api/v1/invitations/{token}/accept/ |
Accept (adds invitee to owners) |
POST /api/v1/invitations/{token}/decline/ |
Decline |
The management actions are defined on BankAccountViewSet and the
public endpoints as function views, both in
app/moneypools/api/v1/views.py.
IsAccountOwner (you can only invite people to accounts you own) and
RequiresInteractiveAuth – API keys cannot send, list, or cancel
invitations. See authentication.md for the
machine-credential policy.cancel_invitation in
app/moneypools/api/v1/views.py
checks invited_by).create_invitation() rejects an
invitee_email that is already an owner
(InviteeAlreadyOwnerError) or already has a pending, unexpired
invitation for the same account (InvitationAlreadyPendingError).On acceptance or decline, the inviter is notified through the
notification service (CO_OWNER_INVITATION_ACCEPTED /
CO_OWNER_INVITATION_DECLINED in
app/moneypools/notification_kinds.py),
so a pending invitation never silently resolves.
Service layer:
app/users/invitation.py.
There is deliberately no REST endpoint for creating user
invitations – they are an operator action, performed in the Django
admin (UserInvitationAdmin in
app/users/admin.py). The admin form
re-validates the same three service-layer conditions (active account
exists, pending invitation exists, rolling window exceeded) so errors
surface inline instead of as a 500. Resend and cancel are bulk admin
actions; the detail view is a read-only audit trail.
create_user_invitation() rejects addresses that already have an
active account (InviteeAlreadyRegisteredError). Acceptance activates
the placeholder user and triggers the first-password reset email, same
as the co-owner flow – the only difference is that no bank-account
ownership is granted.
Two complementary limits protect invitee mailboxes from being flooded
(by an over-eager inviter or an abusive one). Both are enforced in the
shared helpers check_resend() and window_count() in
app/common/invitation.py, with the
knobs defined in app/config/settings.py:
| Setting | Default | Meaning |
|---|---|---|
INVITATION_EXPIRY_DAYS |
7 | Token TTL |
INVITATION_MAX_RESENDS |
3 | Max resends of a single invitation (4 emails incl. the original) |
INVITATION_RESEND_COOLDOWN_HOURS |
1 | Minimum gap between sends of the same invitation |
INVITATION_MAX_PER_WINDOW |
5 | Max invitations to one address in the rolling window |
INVITATION_WINDOW_DAYS |
30 | Length of the rolling window |
Per-invitation resend limit. Each invitation tracks send_count
(starts at 1 for the original send) and last_sent_at. A resend is
refused while the cooldown is active (ResendCooldownActiveError) and
once the resend cap is used up (ResendLimitReachedError). The
resend-limit error message tells the inviter how many new invitations
remain in the rolling window, so they can decide whether
cancel-and-re-invite is worthwhile.
Per-address rolling window. No more than
INVITATION_MAX_PER_WINDOW invitations – of any status – may be
created for a given address in any INVITATION_WINDOW_DAYS-day window.
This exists specifically to close the loophole in the first limit:
without it, cancelling an invitation and creating a fresh one would
reset the resend counter indefinitely. Because cancelled and expired
invitations still count toward the window, cancel-and-re-invite is
bounded too.
The window is scoped differently per flow, matching what “the same invitation” means in each:
window_count(UserInvitation, email)).window_count(..., extra_filters={"bank_account": account})) – so
inviting one person to several different accounts is not throttled,
but hammering one person about one account is.Over the API, a window-exceeded invite returns 429 Too Many Requests.
In the Django admin, both limits surface as inline form/action messages.
| Piece | Location |
|---|---|
| Shared exceptions, rate limiting, email dispatch | app/common/invitation.py |
| Token generation | app/common/tokens.py |
| New-user onboarding helpers | app/users/onboarding.py |
| Co-owner service layer | app/moneypools/service/invitation.py |
Co-owner model (BankAccountInvitation) |
app/moneypools/models.py |
| Co-owner REST endpoints | app/moneypools/api/v1/views.py |
| Co-owner acceptance page | app/moneypools/invitation_views.py |
| Admin-invite service layer | app/users/invitation.py |
Admin-invite model (UserInvitation) |
app/users/models.py |
| Admin-invite admin UI | app/users/admin.py |
| Admin-invite acceptance page | app/users/invitation_views.py |
| Acceptance page URLs | app/moneypools/invitation_urls.py |
| Settings knobs | app/config/settings.py (Users app section) |