DEV Community

Cover image for Building Shared Accounts in Finovara
Marcin Parśniak
Marcin Parśniak

Posted on

Building Shared Accounts in Finovara

When I added shared accounts to Finovara (two users sharing one wallet, expenses, limits, and piggy banks), I knew it would touch almost every service I already had — auth-backend, finance-backend, notification-backend. This post walks through three parts of that feature that I think turned out well: the invitation flow, settings that live per-pair-of-users, and safely tearing down shared financial data when someone leaves.

The Core Idea: ownerId + memberId, Not a Shared User Table

A shared account in Finovara isn't a separate "super user." It's a pairing of two existing accounts — an owner and a member — and every shared entity (wallet, expenses, limits, piggy banks, settings) just carries both IDs:

@Column(nullable = false)
private Long ownerId;

@Column(nullable = false)
private Long memberId;
Enter fullscreen mode Exit fullscreen mode

This keeps things simple: no new authentication concept, no "shared user" identity to manage. Just two foreign keys and a unique constraint on (owner_id, member_id) to guarantee one shared entity per pair.

Part 1: The Invitation Flow

Before two users can share anything, one has to invite the other. Here's the validation layer that guards this:

public void validateSendInvitation(Long inviterUserId, Long inviteeUserId) {
    if (inviterUserId.equals(inviteeUserId)) {
        log.warn("Rejected invitation: userId={} tried to invite themselves", inviterUserId);
        throw new InvalidInputException("You cannot invite yourself");
    }

    if (sharedAccountMemberRepository.existsByUserId(inviterUserId) || sharedAccountMemberRepository.existsByUserId(inviteeUserId)) {
        log.warn("Rejected invitation: inviterUserId={} or inviteeUserId={} already has a shared account", inviterUserId, inviteeUserId);
        throw new EntityAlreadyExistsException("One of the users already belongs to a shared account");
    }

    sharedAccountInvitationRepository.findInvitationBetweenUsers(inviterUserId, inviteeUserId)
            .ifPresent(invitation -> {
                log.warn("Rejected invitation: invitation already exists between inviterUserId={} and inviteeUserId={}", inviterUserId, inviteeUserId);
                throw new EntityAlreadyExistsException("Invitation already exists between these users!");
            });
}
Enter fullscreen mode Exit fullscreen mode

Three checks, three clear failure reasons: no self-invites, no double-membership (a user can only belong to one shared account), and no duplicate pending invitations. Each rejection is logged with the relevant IDs, which turned out to be invaluable later when debugging race conditions between two people accepting/cancelling invitations at nearly the same time.

Sending the Invitation — and Firing Off Three Different Events

Here's where it gets interesting. A single sendInvitation call needs to do three unrelated things: persist the invitation, record it in the activity log, and notify the invitee. Instead of coupling those responsibilities together, I lean on the outbox pattern:

@Transactional
public void sendInvitation(Long inviterUserId, Long inviteeUserId) {
    invitationValidator.validateSendInvitation(inviterUserId, inviteeUserId);

    // ...load inviter/invitee basic info...

    SharedAccountInvitation invitation = SharedAccountInvitation.builder()
            .inviterUserId(inviterUserId)
            .inviteeUserId(inviteeUserId)
            .createdAt(LocalDateTime.now())
            .build();

    sharedAccountInvitationRepository.save(invitation);

    outboxService.save("User", inviterUserId.toString(), "activity.shared-account",
            new SharedAccountActivityEvent(inviterUserId, SharedAccountActivityType.SENT_INVITATION, null, invitee.username(), invitee.email(), LocalDateTime.now()));

    outboxService.save("User", inviterUserId.toString(), "notification.shared-account.invitation-sent",
            new UserSentSharedAccountInvitationEvent(inviteeUserId, inviter.username()));

    log.info("Created invitation id={}, inviterUserId={}, inviteeUserId={}", invitation.getId(), inviterUserId, inviteeUserId);
}
Enter fullscreen mode Exit fullscreen mode

The key detail: sharedAccountInvitationRepository.save(...) and both outboxService.save(...) calls happen inside the same @Transactional boundary. Since the outbox table lives in the same database as the invitation itself, all three inserts commit or roll back together. There's no scenario where an invitation is created but the notification event silently disappears because Kafka happened to be down for a second — the event just sits in the outbox table until a relay process picks it up and publishes it.

This is the same reasoning I apply to every cross-service side-effect in Finovara now: if it needs to be atomic with a database write, it goes through the outbox — never a direct kafkaTemplate.send() in the middle of a transaction.

Accepting an Invitation Creates the Shared Account — and Cascades Four More Events

@Transactional
public void acceptInvite(Long inviteeUserId, Long invitationId) {
    SharedAccountInvitation invitation = loadAndValidateInvitation(inviteeUserId, invitationId);
    Long inviterUserId = invitation.getInviterUserId();

    invitationValidator.validateAcceptInvite(inviterUserId, inviteeUserId, invitationId);

    // ...load user data...

    sharedAccountInvitationRepository.deleteInvitationById(invitationId);

    SharedAccount sharedAccount = sharedAccountRepository.save(
            SharedAccount.builder().createdAt(LocalDateTime.now()).build());

    createMember(sharedAccount, inviterUserId, SharedRole.OWNER);
    createMember(sharedAccount, inviteeUserId, SharedRole.MEMBER);

    outboxService.save("User", inviterUserId.toString(),
            "finance.shared-account.invitation-accepted",
            new UsersCreatedSharedAccountEvent(inviterUserId, inviteeUserId));

    outboxService.save("User", inviteeUserId.toString(),
            "finance.shared-account.create-default-settings",
            new SharedAccountCreateDefaultSettingsEvent(inviterUserId, inviteeUserId));

    outboxService.save("User", inviteeUserId.toString(), "activity.shared-account",
            new SharedAccountActivityEvent(inviteeUserId, SharedAccountActivityType.ACCEPTED_INVITATION, null,
                    inviter.username(), inviter.email(), LocalDateTime.now()));

    outboxService.save("User", inviterUserId.toString(),
            "notification.shared-account.invitation-accepted",
            new UserAcceptSharedAccountInvitationEvent(inviterUserId, invitee.username()));
}
Enter fullscreen mode Exit fullscreen mode

One accepted invitation, four downstream events, and auth-backend doesn't need to know or care what any of them trigger. finance-backend listens for finance.shared-account.invitation-accepted and creates the shared wallet; it listens for finance.shared-account.create-default-settings and provisions a default SharedAccountSettings row; activity-log-backend and notification-backend handle their own topics independently. Each service only knows its own responsibility — that's the whole point of the split.

Part 2: Settings That Belong to a Pair, Not a User

This is the part I initially got wrong in my head before writing any code: shared account settings aren't owned by a single user — they're owned by the relationship between two users. That's why SharedAccountSettings (and every shared entity, really) is looked up by:

@Query("SELECT sa FROM SharedAccountSettings sa WHERE sa.ownerId = :userId OR sa.memberId = :userId")
SharedAccountSettings findByUserId(Long userId);
Enter fullscreen mode Exit fullscreen mode

Either party can query "their" shared settings, and they both get back the same row. A toggle like "notify me on large expenses" isn't "my setting", it's "our setting." This single design decision cascades through every feature built on top of shared accounts: spend control, expense analysis, large-expense notifications, piggy bank goal notifications — they all key off the same (ownerId, memberId) pair.

Part 3: Deleting a Shared Account Without Losing Money

This is the part I'm proudest of. When one person leaves a shared account (or deletes their user account entirely), Finovara has to:

  1. Refund whatever money each person actually contributed, based on their net revenue minus expenses within the shared account.
  2. Delete every trace of shared financial data — expenses, revenues, wallet, piggy banks, limits, notes, settings.
  3. Evict stale cache entries so neither party sees a phantom balance.
  4. Do all of this idempotently, because Kafka will redeliver messages.

Idempotency First

@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean deleteData(SharedAccountDeletedEvent event) {
    Long ownerId = Objects.requireNonNull(event.ownerId(), "event.ownerId must not be null");
    Long memberId = Objects.requireNonNull(event.memberId(), "event.memberId must not be null");

    if (!sharedWalletRepository.existsByOwnerIdAndMemberId(ownerId, memberId)) {
        log.info("Shared account financial data already deleted for accountId={}, ownerId={}, memberId={} " +
                "(duplicate Kafka delivery) — skipping.", event.accountId(), ownerId, memberId);
        return false;
    }

    refundContributedRevenue(event);
    deleteSharedFinancialData(ownerId, memberId);
    return true;
}
Enter fullscreen mode Exit fullscreen mode

Before doing anything destructive, I check whether the shared wallet still exists. If it doesn't, this event has already been processed once — return early, log it clearly as a duplicate delivery, and don't touch anything twice. This one if statement is what makes the whole consumer safe to retry.

Refunding Fairly When Both Sides Contributed Differently

The interesting logic is in figuring out who gets what back. Each person's net contribution is revenue - expense within the shared account:

private record NetContribution(BigDecimal revenue, BigDecimal expense) {
    BigDecimal net() {
        return revenue.subtract(expense);
    }
    boolean isNegative() {
        return net().compareTo(BigDecimal.ZERO) < 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

But what happens if one person's net contribution is negative — they spent more of the shared pool than they put in? You can't refund them money they never contributed. So the logic redistributes:

if (ownerContribution.isNegative()) {
    ownerRefund = BigDecimal.ZERO;
    memberRefund = ownerContribution.net().add(memberContribution.net()).max(BigDecimal.ZERO);
} else if (memberContribution.isNegative()) {
    memberRefund = BigDecimal.ZERO;
    ownerRefund = ownerContribution.net().add(memberContribution.net()).max(BigDecimal.ZERO);
} else {
    ownerRefund = ownerContribution.net();
    memberRefund = memberContribution.net();
}
Enter fullscreen mode Exit fullscreen mode

If one side is in the negative, they get nothing back, and the other side's refund is the combined net — but clamped at zero, so nobody ever gets refunded a negative amount by accident. Each refund path also gracefully skips if the target user's personal wallet no longer exists (a legitimate edge case when both users happen to delete their accounts around the same time):

try {
    walletService.addBalanceToWallet(instruction.userId(), instruction.amount());
} catch (RequestedEntityNotFoundException ex) {
    log.warn("Refund skipped for userId={} — personal wallet no longer exists " +
            "(likely concurrent full account deletion). amount={}", instruction.userId(), instruction.amount());
    return;
}
Enter fullscreen mode Exit fullscreen mode

Cleanup and Cache Eviction Are Separate Concerns

The deletion handler itself stays intentionally thin:

public void handle(SharedAccountDeletedEvent event) {
    boolean wasDeleted = financeDataService.deleteData(event);
    if (wasDeleted) {
        cacheEvictionService.evictAfterSharedAccountDeletion(event.ownerId(), event.memberId());
    }
}
Enter fullscreen mode Exit fullscreen mode

Cache eviction only runs if data was actually deleted (skipping it on duplicate deliveries, same as everything else), and it's defensive about failure — a cache eviction that throws shouldn't take down the whole deletion process, since the cache entry will simply expire on its own TTL:

private void evictIfPresent(String cacheName, Object key) {
    try {
        var cache = cacheManager.getCache(cacheName);
        if (cache != null) {
            cache.evict(key);
        }
    } catch (Exception ex) {
        log.warn("Cache eviction failed for cache={}, key={} — entry may be stale until TTL expiry.",
                cacheName, key, ex);
    }
}
Enter fullscreen mode Exit fullscreen mode

Wiring It All Into the Expense Flow

All of this settings/notification infrastructure ultimately plugs into the same place: adding a shared expense.

@Transactional
public SharedExpenseResponse addExpense(SharedExpenseRequest sharedExpenseRequest, Long userId) {
    // ...validation...

    spendControlService.handleSpendControl(userId, amount);
    validateLimitOrThrow(userId, category, category, BigDecimal.ZERO, amount);
    expenseAnalysisService.handleExpenseAnalysis(userId, sharedExpenseRequest.confirmPasswordDto(), amount, ExpenseAnalysisMode.ADD);

    // ...build and save the expense...

    sharedWalletService.removeBalanceFromWallet(userId, expense.getAmount());
    sharedExpenseRepository.save(expense);

    largeExpenseNotificationService.handleLargeNotification(userId, expense);

    return new SharedExpenseResponse(expense.getId(), userId, createdByUsername);
}
Enter fullscreen mode Exit fullscreen mode

Every one of those handle* calls is a settings-driven feature that can be toggled independently — spend limits, anomaly detection requiring a password, and large-expense email alerts — but the expense-adding flow itself doesn't know or care about the details of any of them. Each service checks its own toggle, does its own thing, and returns.

What This Taught Me

Model relationships, not just entities. Once I stopped thinking of shared account settings as "a setting on user A" and started thinking of them as "a setting on the pair (A, B)," a whole category of bugs (which side's toggle wins?) simply disappeared.

The outbox pattern pays for itself fast. Every place where a business action needs to also notify another service, wrapping it in outboxService.save(...) inside the same transaction means I never have to reason about "what if Kafka was down for that one request."

Idempotency isn't optional for event consumers. The existsByOwnerIdAndMemberId check in the deletion handler is three lines of code that turn "retry-safe" from a hope into a guarantee.

Thanks for reading!
My github:

GitHub logo M4rc1nek / finovara-backend

Backend service for a personal finance management application

💰 Finovara — Backend

Backend REST API for a personal finance management application built with Java 25 and Spring Boot 4.


📖 About the Project

Finovara is a personal finance platform designed to help users take full control of their money. The backend exposes a secure REST API that powers tracking of income and expenses, budget management, savings goals, and financial reporting — all wrapped in a bank-grade security model based on JWT authentication.

The application is designed with scalability in mind and is fully containerized via Docker, with separate production and test database environments managed through Docker Compose.


🎯 Key Features

  • 🔐 Authentication & Authorization — JWT-based stateless security with Spring Security; access and refresh token flow with device/user-agent detection
  • 💸 Income & Expense Tracking — full CRUD for financial operations with category tagging
  • 📊 Statistics & Reports — aggregated financial summaries, spending trends, and exportable PDF reports
  • 🏦…




Top comments (0)