mibudge

Invitations

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).


Invitation lifecycle

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.

The token is the credential

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.

Acceptance pages live outside the SPA

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.

New-user onboarding: no password shortcut

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).


Co-owner invitations

Service layer: app/moneypools/service/invitation.py.

Endpoints

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.

Protections

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.


Admin user invitations

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.


Rate limiting

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:

Over the API, a window-exceeded invite returns 429 Too Many Requests. In the Django admin, both limits surface as inline form/action messages.


Implementation map

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)