Back to 0fee
0fee

Wallet and Add Funds Flow

How 0fee.dev's wallet system works: credits, add-funds flow, country-based filtering, coupons, and balance management. By Juste A. Gnimavo and Claude.

Thales & Claude | March 25, 2026 10 min 0fee
walletcreditspayments

Every 0fee.dev merchant has a wallet. It is the internal ledger that tracks platform fee accruals, credits, add-funds deposits, and coupon bonuses. The wallet is not a bank account -- it is an accounting tool that determines whether a merchant's platform fees are covered.

The Credits System

The wallet operates on a credits model. Credits are denominated in USD and represent prepaid platform fees. When a merchant processes a transaction, the 0.99% fee is deducted from their credit balance:

pythonclass WalletTransaction(Base):
    __tablename__ = "wallet_transactions"

    id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
    app_id: Mapped[UUID] = mapped_column(ForeignKey("apps.id"))
    user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"))

    type: Mapped[str]
    # credit: add_funds, coupon, refund_reversal, admin_credit
    # debit: platform_fee, adjustment

    amount_usd: Mapped[Decimal]  # Always positive
    balance_after: Mapped[Decimal]  # Snapshot of balance after this entry

    description: Mapped[str]
    reference: Mapped[Optional[str]]
    metadata: Mapped[Optional[dict]] = mapped_column(JSON)

    created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)

Fee Calculation and Deduction

When a transaction completes, the platform fee is calculated and deducted:

pythonasync def deduct_platform_fee(transaction: Transaction, db: AsyncSession):
    """Deduct the platform fee from the merchant's wallet."""
    fee_usd = calculate_fee(transaction.amount, transaction.source_currency)

    # Get current balance
    wallet = await get_wallet(transaction.app_id, db)

    # Create debit entry
    wallet_tx = WalletTransaction(
        app_id=transaction.app_id,
        user_id=transaction.user_id,
        type="platform_fee",
        amount_usd=fee_usd,
        balance_after=wallet.balance - fee_usd,
        description=f"Platform fee for {transaction.reference}",
        reference=transaction.reference,
    )

    # Update balance (can go negative)
    wallet.balance -= fee_usd

    db.add(wallet_tx)
    await db.commit()

    # Store fee on transaction
    transaction.platform_fee_usd = fee_usd

The critical design choice: balance can go negative. We never block a payment because the merchant's credit balance is insufficient. The negative balance accrues and appears on the monthly invoice.

The Add Funds Flow

Merchants add funds to their wallet through the hosted checkout -- the same checkout system that powers customer-facing payments. We eat our own dog food.

Step 1: Country Selection

The add-funds flow starts with country selection. This determines which payment methods are available:

svelte<script lang="ts">
  import { Globe } from 'lucide-svelte';

  let selectedCountry = $state('');
  let paymentMethods = $state<PaymentMethod[]>([]);

  const countries = [
    { code: 'CI', name: "Ivory Coast", flag: 'CI', currency: 'XOF' },
    { code: 'SN', name: 'Senegal', flag: 'SN', currency: 'XOF' },
    { code: 'NG', name: 'Nigeria', flag: 'NG', currency: 'NGN' },
    { code: 'GH', name: 'Ghana', flag: 'GH', currency: 'GHS' },
    { code: 'KE', name: 'Kenya', flag: 'KE', currency: 'KES' },
    { code: 'ZA', name: 'South Africa', flag: 'ZA', currency: 'ZAR' },
    { code: 'US', name: 'United States', flag: 'US', currency: 'USD' },
    { code: 'GB', name: 'United Kingdom', flag: 'GB', currency: 'GBP' },
    { code: 'FR', name: 'France', flag: 'FR', currency: 'EUR' },
  ];

  async function onCountrySelect(countryCode: string) {
    selectedCountry = countryCode;
    const response = await fetch(
      `/api/payment-methods?country=${countryCode}&context=add_funds`
    );
    const data = await response.json();
    paymentMethods = data.methods;
  }
</script>

<div class="space-y-4">
  <h3 class="text-lg font-semibold">Select Your Country</h3>
  <p class="text-sm text-gray-500">
    Choose your country to see available payment methods.
  </p>

  <div class="grid grid-cols-2 md:grid-cols-3 gap-3">
    {#each countries as country}
      <button
        onclick={() => onCountrySelect(country.code)}
        class="flex items-center gap-3 p-3 rounded-lg border transition-all
               {selectedCountry === country.code
                 ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
                 : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'}"
      >
        <span class="text-2xl">{country.flag}</span>
        <div class="text-left">
          <div class="font-medium text-sm">{country.name}</div>
          <div class="text-xs text-gray-400">{country.currency}</div>
        </div>
      </button>
    {/each}
  </div>
</div>

Step 2: Amount Selection

Merchants choose from preset amounts or enter a custom value:

svelte<script lang="ts">
  let selectedAmount = $state(0);
  let customAmount = $state('');
  let couponCode = $state('');
  let couponDiscount = $state<CouponResult | null>(null);

  const presets = [5, 10, 25, 50, 100, 250];

  let effectiveAmount = $derived(() => {
    const base = selectedAmount || parseFloat(customAmount) || 0;
    if (!couponDiscount) return base;

    if (couponDiscount.type === 'percentage') {
      return base * (1 - couponDiscount.value / 100);
    }
    return Math.max(0, base - couponDiscount.value);
  });

  let feeEstimate = $derived(() => {
    const amount = selectedAmount || parseFloat(customAmount) || 0;
    // How many transactions does this cover?
    // At 0.99%, $10 covers ~$1,010 in transaction volume
    return amount > 0 ? Math.round(amount / 0.0099) : 0;
  });
</script>

<div class="space-y-6">
  <h3 class="text-lg font-semibold">Choose Amount</h3>

  <div class="grid grid-cols-3 gap-3">
    {#each presets as amount}
      <button
        onclick={() => { selectedAmount = amount; customAmount = ''; }}
        class="py-3 rounded-lg border text-center font-semibold transition-all
               {selectedAmount === amount
                 ? 'border-blue-500 bg-blue-500 text-white'
                 : 'border-gray-200 dark:border-gray-700 hover:border-blue-300'}"
      >
        ${amount}
      </button>
    {/each}
  </div>

  <div>
    <label class="block text-sm font-medium mb-1">Custom Amount (USD)</label>
    <input
      type="number"
      min="1"
      step="0.01"
      bind:value={customAmount}
      oninput={() => selectedAmount = 0}
      placeholder="Enter amount"
      class="w-full px-3 py-2 rounded-lg border border-gray-200
             dark:border-gray-700 dark:bg-gray-800"
    />
  </div>

  {#if feeEstimate() > 0}
    <div class="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg text-sm">
      <span class="font-medium">
        ${selectedAmount || customAmount} covers approximately
        ${feeEstimate().toLocaleString()} in transaction volume.
      </span>
    </div>
  {/if}
</div>

Step 3: Coupon Application

The coupon system supports both percentage and fixed-amount discounts:

pythonclass Coupon(Base):
    __tablename__ = "coupons"

    id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
    code: Mapped[str] = mapped_column(String(20), unique=True, index=True)
    type: Mapped[str]  # "percentage" or "fixed"
    value: Mapped[Decimal]  # Percentage (0-100) or USD amount
    max_uses: Mapped[Optional[int]]
    used_count: Mapped[int] = mapped_column(default=0)
    min_amount: Mapped[Optional[Decimal]]  # Minimum add-funds amount
    max_discount: Mapped[Optional[Decimal]]  # Cap on percentage discounts
    expires_at: Mapped[Optional[datetime]]
    active: Mapped[bool] = mapped_column(default=True)
    created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)

@router.post("/api/coupons/validate") async def validate_coupon( code: str = Body(...), amount: Decimal = Body(...), db: AsyncSession = Depends(get_db), ): """Validate and calculate coupon discount.""" coupon = await db.execute( select(Coupon).where( Coupon.code == code.upper(), Coupon.active == True, ) ) coupon = coupon.scalar_one_or_none() BLANK if not coupon: raise HTTPException(status_code=404, detail="Invalid coupon code") BLANK if coupon.expires_at and coupon.expires_at < datetime.utcnow(): raise HTTPException(status_code=400, detail="Coupon has expired") BLANK if coupon.max_uses and coupon.used_count >= coupon.max_uses: raise HTTPException(status_code=400, detail="Coupon usage limit reached") BLANK if coupon.min_amount and amount < coupon.min_amount: raise HTTPException( status_code=400, detail=f"Minimum amount for this coupon is ${coupon.min_amount}" ) BLANK # Calculate discount if coupon.type == "percentage": discount = amount * coupon.value / 100 if coupon.max_discount: discount = min(discount, coupon.max_discount) else: discount = min(coupon.value, amount) BLANK return { "valid": True, "type": coupon.type, "value": float(coupon.value), "discount_usd": float(discount), "final_amount": float(amount - discount), } ```

Step 4: Hosted Checkout

The add-funds flow uses 0fee.dev's own hosted checkout to process the payment. This is the ultimate dogfooding -- our billing system runs through our own payment orchestrator:

python@router.post("/api/wallet/add-funds")
async def initiate_add_funds(
    amount_usd: Decimal = Body(...),
    country: str = Body(...),
    payment_method: Optional[str] = Body(None),
    coupon_code: Optional[str] = Body(None),
    app_id: UUID = Depends(get_current_app),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Create a hosted checkout session for adding funds."""
    # Apply coupon if provided
    final_amount = amount_usd
    coupon = None
    if coupon_code:
        coupon = await validate_and_apply_coupon(coupon_code, amount_usd, db)
        final_amount = amount_usd - coupon.discount

    # Convert to local currency for checkout
    local_currency = get_country_currency(country)
    local_amount = await convert_currency(final_amount, "USD", local_currency)

    # Create checkout session (using our own API)
    checkout = await create_checkout_session(
        app_id=app_id,
        amount=local_amount,
        currency=local_currency,
        payment_reference=f"ADDFUNDS-{uuid4().hex[:8]}",
        customer_email=user.email,
        metadata={
            "type": "add_funds",
            "credits_usd": str(amount_usd),  # Full amount, not discounted
            "coupon_code": coupon_code,
            "coupon_discount": str(coupon.discount) if coupon else "0",
        },
        success_url=f"https://app.0fee.dev/wallet?funded=true",
        cancel_url=f"https://app.0fee.dev/wallet",
    )

    return {"checkout_url": checkout.url}

When the payment completes, a webhook credits the wallet:

pythonasync def handle_add_funds_webhook(transaction: Transaction, db: AsyncSession):
    """Credit wallet when add-funds payment completes."""
    metadata = transaction.metadata or {}

    if metadata.get("type") != "add_funds":
        return

    credits_usd = Decimal(metadata["credits_usd"])
    coupon_discount = Decimal(metadata.get("coupon_discount", "0"))

    # Credit the full amount (original + coupon bonus)
    total_credits = credits_usd

    wallet = await get_wallet(transaction.app_id, db)

    wallet_tx = WalletTransaction(
        app_id=transaction.app_id,
        user_id=transaction.user_id,
        type="add_funds",
        amount_usd=total_credits,
        balance_after=wallet.balance + total_credits,
        description=f"Added funds: ${total_credits}",
        reference=transaction.reference,
        metadata={
            "payment_amount": str(credits_usd - coupon_discount),
            "coupon_bonus": str(coupon_discount),
        },
    )

    wallet.balance += total_credits
    db.add(wallet_tx)
    await db.commit()

Notice a key detail: when a coupon gives 20% off, the merchant pays $8 for $10 in credits but receives the full $10. The coupon discount is a real credit bonus, not just a payment reduction.

Balance Display

The wallet balance is prominently displayed in the dashboard:

svelte<script lang="ts">
  import { ArrowUpRight, ArrowDownRight, History } from 'lucide-svelte';

  let { wallet } = $props<{ wallet: WalletData }>();

  let balanceColor = $derived(() => {
    if (wallet.balance > 10) return 'text-green-600 dark:text-green-400';
    if (wallet.balance > 0) return 'text-yellow-600 dark:text-yellow-400';
    return 'text-red-600 dark:text-red-400';
  });
</script>

<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border
            border-gray-200 dark:border-gray-700">
  <div class="flex items-center justify-between mb-4">
    <h3 class="text-sm font-medium text-gray-500">Wallet Balance</h3>
    <a href="/wallet" class="text-sm text-blue-500 hover:text-blue-600">
      Manage
    </a>
  </div>

  <div class="text-3xl font-bold {balanceColor()}">
    ${wallet.balance.toFixed(2)}
  </div>

  <div class="mt-4 grid grid-cols-2 gap-4 text-sm">
    <div class="flex items-center gap-2">
      <ArrowUpRight class="w-4 h-4 text-green-500" />
      <span class="text-gray-500">Credits this month</span>
      <span class="font-medium">${wallet.credits_this_month.toFixed(2)}</span>
    </div>
    <div class="flex items-center gap-2">
      <ArrowDownRight class="w-4 h-4 text-red-500" />
      <span class="text-gray-500">Fees this month</span>
      <span class="font-medium">${wallet.fees_this_month.toFixed(2)}</span>
    </div>
  </div>

  {#if wallet.balance < 0}
    <div class="mt-4 bg-red-50 dark:bg-red-900/20 text-red-700
                dark:text-red-300 p-3 rounded-lg text-sm">
      Your balance is negative. Fees will appear on your next invoice.
    </div>
  {/if}
</div>

Transaction History

The wallet page includes a complete transaction history:

python@router.get("/api/wallet/transactions")
async def list_wallet_transactions(
    app_id: UUID = Depends(get_current_app),
    type: Optional[str] = Query(None),  # credit, debit, all
    page: int = Query(1, ge=1),
    per_page: int = Query(50, ge=1, le=100),
    db: AsyncSession = Depends(get_db),
):
    """List wallet transaction history."""
    query = (
        select(WalletTransaction)
        .where(WalletTransaction.app_id == app_id)
    )

    if type and type != "all":
        credit_types = ["add_funds", "coupon", "refund_reversal", "admin_credit"]
        if type == "credit":
            query = query.where(WalletTransaction.type.in_(credit_types))
        elif type == "debit":
            query = query.where(~WalletTransaction.type.in_(credit_types))

    total = await db.scalar(
        select(func.count()).select_from(query.subquery())
    )

    transactions = await db.execute(
        query.order_by(WalletTransaction.created_at.desc())
        .offset((page - 1) * per_page)
        .limit(per_page)
    )

    return {
        "transactions": [
            {
                "id": str(tx.id),
                "type": tx.type,
                "amount_usd": float(tx.amount_usd),
                "balance_after": float(tx.balance_after),
                "description": tx.description,
                "reference": tx.reference,
                "created_at": tx.created_at.isoformat(),
            }
            for tx in transactions.scalars().all()
        ],
        "total": total,
        "page": page,
    }

The Credit Limit Evolution

The wallet system went through a significant evolution regarding credit limits.

Original design (tiered limits):

TierCredit LimitMonthly Fee
Starter-$10$0
Growth-$100$29
Business-$500$99
EnterpriseUnlimitedCustom

This created perverse incentives. A merchant processing $10,000/month in transactions could hit their -$10 credit limit after just a few days, forcing them to upgrade to a paid tier just to keep processing.

Current design (unlimited negative balance):

When we moved to the flat 0.99% model in Session 015, we removed all credit limits. The new approach:

  1. Fees accrue against the balance without limit.
  2. Monthly invoice includes all accrued fees.
  3. Payment is due by the 5th.
  4. Suspension only on the 10th if unpaid.

This means a merchant's wallet can show -$500, -$1,000, or more. The trust model is: let them grow, bill them monthly, enforce only when necessary.

The results validated this approach. Merchants who would have been blocked under the tiered system processed significantly more volume, generating more fee revenue for 0fee.dev than any subscription tier would have.

Edge Cases

Refund Fee Reversal

When a transaction is refunded, the platform fee is credited back to the wallet:

pythonasync def reverse_fee_on_refund(transaction: Transaction, db: AsyncSession):
    wallet = await get_wallet(transaction.app_id, db)

    wallet_tx = WalletTransaction(
        app_id=transaction.app_id,
        user_id=transaction.user_id,
        type="refund_reversal",
        amount_usd=transaction.platform_fee_usd,
        balance_after=wallet.balance + transaction.platform_fee_usd,
        description=f"Fee reversal for refund: {transaction.reference}",
        reference=transaction.reference,
    )

    wallet.balance += transaction.platform_fee_usd
    db.add(wallet_tx)

Coupon Stacking

Coupons do not stack. Only one coupon can be applied per add-funds transaction. This keeps the system simple and prevents coupon abuse.

Currency Mismatch

The wallet is always in USD. If a merchant adds funds in XOF, the payment amount is converted at the current rate, and the wallet is credited in USD at the same rate. The conversion rate is stored in the wallet transaction metadata for auditability.

Why This Design Works

The wallet system balances simplicity with flexibility. Credits are easy to understand: money goes in, fees come out, and the balance reflects the difference. The add-funds flow uses our own checkout, proving the product works while reducing engineering effort. And the unlimited negative balance model trusts merchants by default, which turns out to be the right bet.


This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles