Back to Journal

The phantom 'Fully paid' label

Listen to this articleAI narration
0:00 / 0:00

A user upgrades to the 6-month Pro plan. Opens their settings page. Under "Manage plan," the status reads: Fully paid. Next to it, a renewal date six months out. The plan card below shows 7 770 SEK (paid).

But no fee has been collected. The user hasn't created a single invoice yet. The Pro fee — 7 770 SEK — is collected through invoice commissions. No invoices means no commissions means no payment. The label is lying.

How Pro Fee Collection Works

The Pro plan replaces the percentage-based Flex commission with a fixed fee spread across invoices. When a Pro user sends an invoice and the customer pays it, the platform deducts a portion of the invoice amount toward the Pro fee. This continues until the full fee is collected or the contract period ends.

The system tracks this through three entities that form a payment pipeline:

Pro Fee Collection PipelineProContractUser, plan, cycleCount, startsAtThe agreement — what plan, how longProSubscriptioncycleNumber, cycleStartDate, cycleEndDateIndividual billing period within the contractProReservationamountMinor: 777000 | amountOpenMinor: 777000currency: SEK | paidVia: nullThe actual payment tracker — how much is owed vs collectedProSettlementinvoice, reservedAmount, amountDeductedFromInvoice, paidAtPer-invoice record — what was reserved, what was actually collected

The ProReservation is the source of truth. Its amountMinor field holds the total fee for the period (777 000 öre = 7 770 SEK). Its amountOpenMinor field tracks how much is still outstanding. When amountOpenMinor reaches zero, the reservation is fully closed — the fee has been collected.

The Two-Stage Deduction

Fee collection happens in two stages tied to the invoice lifecycle. When an invoice is sent to a customer, the system reserves a portion of the invoice amount against the Pro fee. When the customer pays the invoice, the reservation converts into an actual deduction.

Invoice-Driven Fee CollectionStage 1: ReservationWhen invoice is sentsettlement.reservedAmount = 50000settlement.amountDeducted = 0settlement.paidAt = nullMoney earmarked, not yet collectedCustomerpaysStage 2: CollectionWhen invoice is paidsettlement.reservedAmount = 0settlement.amountDeducted = 50000settlement.paidAt = 2026-01-23Money actually collected from invoiceExample: 6-month Pro plan (7 770 SEK)Invoice 1 paid (net 50 000 SEK):collected 500 SEK → remaining 7 270 SEKInvoice 2 paid (net 120 000 SEK):collected 1 200 SEK → remaining 6 070 SEKNo more invoices yet:6 070 SEK still outstanding...continues until amountOpenMinor reaches 0

The ProBillingService orchestrates this. When calculating the effective commission rate for a Pro user, it checks the reservation's available capacity and allocates up to the invoice's net amount:

$capacity = $reservationAllocation->availableMinor;
$applied = min($capacity, $netMinor);
$effectiveRate = $applied / $netMinor;

So when a Pro user has amountOpenMinor = 777000 and no invoices, the reservation hasn't started collecting anything. The fee is entirely outstanding.

Where It Went Wrong

The isFullyPaid flag in ProContractProvider.php was computed like this:

// cycleCount == 1 means ONE-TIME payment (fully paid)
// cycleCount > 1 means RECURRING payments (not fully paid)
$isFullyPaid = $cycleCount === 1 && $now < $contractEndDate;

The condition had nothing to do with actual payments. It checked cycleCount — a structural property of the plan. A 6-month full plan has cycleCount = 1 (one billing cycle covering the entire period). Monthly plans have cycleCount > 1. The code was answering the question "is this a single-cycle plan?" and calling it "is this fully paid?"

What isFullyPaid Was Checking vs What It Should CheckBefore (Contract Structure)cycleCount === 1 && now < contractEndDateAlways true for full-plan usersIgnores whether any money was collectedAfter (Reservation Status)reservation.isFullyClosed() // amountOpenMinor === 0Only true when fee is fully collectedReflects actual payment statusImpact on DisplayNew user, 0 invoices:Before: "Fully paid"After: "7 770 SEK remaining"User with partial payment:Before: "Fully paid"After: "5 230 SEK remaining"User fully collected:Before: "Fully paid"After: "Fully paid"

The irony is that the system already had accurate payment tracking. The ProReservation entity tracks every öre through amountOpenMinor. The isFullyClosed() method returns true only when that value reaches zero. The ProSettlement records log each individual deduction with timestamps. All the data was there — the provider just never looked at it.

The Provider Fix

The provider already fetches a ProReservationAllocation for the active contract at the top of the method. It uses this to build a ProContractReservationView with capacity details. The allocation object carries the actual reservation entity, which has the isFullyClosed() method and the amountOpenMinor value.

The fix passes the reservation allocation into the mapping closure and checks actual payment status instead of contract structure:

if ($cycleCount === 1) {
    // Full-plan: check actual payment status from reservation
    if ($isActive && null !== $reservationAllocation) {
        $reservation = $reservationAllocation->reservation;
        $isFullyPaid = $reservation->isFullyClosed();
        $nextPaymentMinor = $isFullyPaid ? 0 : $reservation->getAmountOpenMinor();
    } else {
        $isFullyPaid = false;
        $nextPaymentMinor = $totalContractMinor;
    }
    $nextPaymentDate = $contractEndDate;
} else {
    $isFullyPaid = false;
    $nextPaymentMinor = $planPrice;
    $nextPaymentDate = $startsAt->add(new \DateInterval('P1M'));
}

For the active contract with cycleCount === 1, the fix consults the reservation directly. isFullyClosed() returns true only when amountOpenMinor reaches zero — meaning every öre of the fee has been collected through invoice commissions. getAmountOpenMinor() returns the remaining balance for display.

For inactive or past contracts, it falls back to false and shows the total contract amount. Monthly contracts (cycleCount > 1) remain unchanged — they were never affected by this bug.

The Frontend Cascade

The isFullyPaid flag drives conditional rendering in three components. The settings page shows either "Fully paid" or the remaining amount. Two plan card components — one on the settings page, one in the sidebar — show either "7 770 SEK (paid)" or a price label.

Frontend Data FlowGET /me/pro-contractsuseSubscription hooksubscription = hydra:member[0]PlanManagementSettings pageisFullyPaid → labelnextPaymentMinor → amountNo changes neededCurrentSettings plan cardWas: "7 770 SEK per month"Now: "7 770 SEK remaining"Added cycleCount checkMyPlanSidebar plan cardWas: "7 770 SEK per month"Now: "7 770 SEK remaining"Added cycleCount check

The PlanManagement component already handled isFullyPaid = false correctly — it shows nextPaymentMinor as a formatted currency and switches the label from "Renewal date" to "Payment due date." No changes needed.

The two plan card components — Current and MyPlan — had a problem. When isFullyPaid flipped from true to false, they fell through to the pro.price translation key: "{{price}} SEK per month". For a full-plan user, showing "7 770 SEK per month" is wrong — 7 770 is the total for six months, not a monthly rate.

Both components needed a three-way branch:

{subscription?.isFullyPaid
  ? t('pro.pricePaid', {price: format(subscription?.totalContractMinor / 100)})
  : subscription?.cycleCount === 1
    ? t('pro.priceRemaining', {price: format(subscription?.nextPaymentMinor / 100)})
    : t('pro.price', {price: format(subscription?.planPrice / 100)})
}

A new translation key pro.priceRemaining was added to both locale files:

  • EN: "{{price}} SEK remaining"
  • SV: "{{price}} SEK kvar"

This gives full-plan users a remaining balance label instead of the misleading "per month" framing, while monthly plan users continue seeing their regular per-month price.

The Pattern

The bug followed a familiar pattern: a display flag computed from structural data instead of actual state. cycleCount === 1 tells you the plan type. reservation.isFullyClosed() tells you the payment status. The original code conflated the two — treating "this is a single-cycle plan" as equivalent to "this plan is fully paid."

The fix was straightforward once the distinction was clear. The reservation entity already tracked every öre through amountOpenMinor. The isFullyClosed() method already existed. The ProSettlement records already logged each deduction. All the infrastructure for accurate payment tracking was in place — the provider just needed to use it.