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_usdThe 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):
| Tier | Credit Limit | Monthly Fee |
|---|---|---|
| Starter | -$10 | $0 |
| Growth | -$100 | $29 |
| Business | -$500 | $99 |
| Enterprise | Unlimited | Custom |
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:
- Fees accrue against the balance without limit.
- Monthly invoice includes all accrued fees.
- Payment is due by the 5th.
- 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.