December 25, 2025. Abidjan, Ivory Coast. While the rest of the world opened presents, Thales opened his laptop and started Session 064. By the end of the day, 0fee.dev had internationalization infrastructure supporting 15 backend languages and 5 frontend languages, completely rewritten Node.js and Python SDKs, a fixed currency system, PDF receipt generation, and a repaired localStorage token key.
Four sessions. Christmas Day. Working from Abidjan. This is what building a startup with an AI CTO looks like when there are no holidays and no weekends.
Session 064: i18n Infrastructure
The internationalization session was the largest of the four. 0fee.dev needed to serve developers worldwide, and English-only was a limitation:
Backend: 15 Languages
python# i18n/languages.py
SUPPORTED_LANGUAGES = {
"en": "English",
"fr": "French",
"es": "Spanish",
"pt": "Portuguese",
"ar": "Arabic",
"zh": "Chinese (Simplified)",
"ja": "Japanese",
"ko": "Korean",
"de": "German",
"it": "Italian",
"nl": "Dutch",
"ru": "Russian",
"tr": "Turkish",
"sw": "Swahili",
"ha": "Hausa",
}Swahili and Hausa were included from the start -- deliberate choices for an Africa-first platform. Swahili covers East Africa (Kenya, Tanzania, Uganda). Hausa covers West Africa (Nigeria, Niger, Ghana).
The backend i18n system was built as a translation dictionary with fallback:
python# i18n/translator.py
import json
from pathlib import Path
class Translator:
def __init__(self):
self._translations: dict[str, dict[str, str]] = {}
self._load_translations()
def _load_translations(self):
i18n_dir = Path(__file__).parent / "translations"
for lang_file in i18n_dir.glob("*.json"):
lang_code = lang_file.stem
with open(lang_file) as f:
self._translations[lang_code] = json.load(f)
def t(self, key: str, lang: str = "en", **kwargs) -> str:
"""Translate a key to the specified language."""
translations = self._translations.get(lang, {})
text = translations.get(key)
if not text:
# Fallback to English
text = self._translations.get("en", {}).get(key, key)
# Interpolate variables
if kwargs:
text = text.format(**kwargs)
return text
translator = Translator() t = translator.t ```
Example translation files:
json// i18n/translations/en.json
{
"payment.pending": "Payment is being processed",
"payment.completed": "Payment completed successfully",
"payment.failed": "Payment failed: {reason}",
"error.rate_limited": "Too many requests. Please try again in {seconds} seconds.",
"invoice.subject": "Invoice #{number} from 0fee.dev",
"email.welcome": "Welcome to 0fee.dev, {name}!"
}json// i18n/translations/fr.json
{
"payment.pending": "Le paiement est en cours de traitement",
"payment.completed": "Paiement effectue avec succes",
"payment.failed": "Le paiement a echoue : {reason}",
"error.rate_limited": "Trop de requetes. Veuillez reessayer dans {seconds} secondes.",
"invoice.subject": "Facture n{number} de 0fee.dev",
"email.welcome": "Bienvenue sur 0fee.dev, {name} !"
}The language is determined from the Accept-Language header, with a query parameter override:
python# middleware/i18n.py
async def get_language(request: Request) -> str:
# Query parameter takes priority
lang = request.query_params.get("lang")
if lang and lang in SUPPORTED_LANGUAGES:
return lang
# Parse Accept-Language header
accept_lang = request.headers.get("accept-language", "en")
for part in accept_lang.split(","):
lang_code = part.split(";")[0].strip()[:2].lower()
if lang_code in SUPPORTED_LANGUAGES:
return lang_code
return "en"Frontend: 5 Languages
The frontend launched with 5 languages initially:
typescript// i18n/index.ts
const FRONTEND_LANGUAGES = {
en: () => import('./locales/en.json'),
fr: () => import('./locales/fr.json'),
es: () => import('./locales/es.json'),
pt: () => import('./locales/pt.json'),
ar: () => import('./locales/ar.json'),
};Arabic support required RTL (right-to-left) layout consideration:
css/* RTL support for Arabic */
[dir="rtl"] .sidebar { right: 0; left: auto; }
[dir="rtl"] .content { margin-right: 250px; margin-left: 0; }
[dir="rtl"] .text-left { text-align: right; }Session 065: Node.js and Python SDK Rewrite
The original SDKs from Session 002 were generated rapidly and had limitations. Session 065 rewrote the two most popular SDKs from scratch:
Node.js SDK
typescript// @0fee/node - v2.0.0
import { ZeroFee } from '@0fee/node';
const zerofee = new ZeroFee({
apiKey: 'sk_live_...',
baseUrl: 'https://api.0fee.dev', // Optional, defaults to production
});
// Create a payment
const payment = await zerofee.payments.create({
amount: 10.00,
currency: 'USD',
reference: 'order-123',
metadata: { orderId: '123' },
});
// List transactions with filters
const transactions = await zerofee.transactions.list({
status: 'completed',
page: 1,
perPage: 50,
});
// Webhook signature verification
const isValid = zerofee.webhooks.verify(
payload,
signature,
webhookSecret,
);The rewrite introduced:
- Zero runtime dependencies (uses native fetch)
- Full TypeScript types for all request/response objects
- Automatic retry with exponential backoff
- Webhook signature verification built-in
- Resource-based API (zerofee.payments, zerofee.transactions, etc.)
Python SDK
python# zerofee-python v2.0.0
from zerofee import ZeroFee
client = ZeroFee(api_key="sk_live_...")
# Create a payment
payment = client.payments.create(
amount=10.00,
currency="USD",
reference="order-123",
)
# Async support
from zerofee import AsyncZeroFee
async_client = AsyncZeroFee(api_key="sk_live_...")
payment = await async_client.payments.create(
amount=10.00,
currency="USD",
reference="order-123",
)The Python SDK rewrite added:
- Both sync (httpx) and async (httpx.AsyncClient) support
- Pydantic models for type validation
- Automatic pagination helpers
- Webhook verification
- Zero dependencies beyond httpx
Session 066: The Currency Bug
This session addressed the persistent currency display issue. The data was stored in dollars (major units), but several code paths still divided by 100, assuming amounts were in cents (minor units):
python# The discovery
# Database: amount = 5.00 (dollars)
# Dashboard display: $0.05 (divided by 100 erroneously)
# Invoice total: $0.05 (same bug)
# Webhook payload: 0.05 (same bug)Session 066 removed all erroneous /100 divisions across 8 files (documented in detail in article 060). The fix was simple -- delete the division. But finding every instance required a systematic search through the entire codebase.
Session 067: PDF Receipt Generation
The final Christmas Day session built the receipt generation system:
python# services/receipt.py
from weasyprint import HTML
from jinja2 import Template
RECEIPT_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<style>
@page { size: A5; margin: 15mm; }
body { font-family: 'Inter', sans-serif; font-size: 10pt; }
.header { display: flex; justify-content: space-between; }
.amount { font-size: 24pt; font-weight: bold; text-align: center; }
.details-table { width: 100%; border-collapse: collapse; }
.details-table td { padding: 4pt 0; border-bottom: 0.5pt solid #eee; }
.footer { text-align: center; color: #666; font-size: 8pt; }
</style>
</head>
<body>
<div class="header">
<div><strong>{{ app_name }}</strong></div>
<div>Receipt #{{ receipt_number }}</div>
</div>
<div class="amount">
{{ formatted_amount }}
</div>
<table class="details-table">
<tr><td>Status</td><td>{{ status }}</td></tr>
<tr><td>Reference</td><td>{{ reference }}</td></tr>
<tr><td>Provider</td><td>{{ provider }}</td></tr>
<tr><td>Method</td><td>{{ payment_method }}</td></tr>
<tr><td>Date</td><td>{{ date }}</td></tr>
{% if destination_amount %}
<tr><td>Converted</td><td>{{ formatted_destination }}</td></tr>
<tr><td>Exchange Rate</td><td>{{ exchange_rate }}</td></tr>
{% endif %}
</table>
<div class="footer">
<p>Powered by 0fee.dev</p>
<p>Transaction ID: {{ transaction_id }}</p>
</div>
</body>
</html>
"""
async def generate_receipt_pdf(transaction_id: str) -> bytes:
transaction = await get_transaction(transaction_id)
app = await get_app(transaction.app_id)
html_content = Template(RECEIPT_TEMPLATE).render(
app_name=app.name,
receipt_number=transaction.id[:12].upper(),
formatted_amount=format_amount(transaction.source_amount, transaction.source_currency),
status=transaction.status.title(),
reference=transaction.reference,
provider=transaction.provider,
payment_method=transaction.payment_method,
date=transaction.created_at.strftime("%B %d, %Y at %H:%M UTC"),
transaction_id=transaction.id,
destination_amount=transaction.destination_amount,
formatted_destination=format_amount(
transaction.destination_amount, transaction.destination_currency
) if transaction.destination_amount else None,
exchange_rate=transaction.exchange_rate,
)
pdf = HTML(string=html_content).write_pdf()
return pdfThe compact A5 layout was a deliberate choice. Receipts are frequently printed in Africa -- particularly for mobile money transactions where paper records are important for business accounting. A5 (half of A4) uses less paper while remaining readable.
The localStorage Token Key Fix
A small but impactful bug fix also happened in this session. The frontend was storing the JWT token under different keys in different components:
typescript// Some components used:
localStorage.getItem("token")
// Other components used:
localStorage.getItem("access_token")
// The auth module used:
localStorage.getItem("0fee_token")Three different keys for the same token. Depending on which component the user interacted with first, the token would be stored under one key and not found by components looking under a different key. The fix was standardizing on a single key:
typescript// Standardized token storage
const TOKEN_KEY = "0fee_access_token";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
}Working on Christmas Day From Abidjan
Christmas in Abidjan is celebrated, but it is not the shutdown that it is in Europe or North America. The city moves at a slower pace, but it moves. And for a bootstrapped startup competing with funded companies in San Francisco and London, every day of building is an advantage.
The four sessions on Christmas Day produced infrastructure that would serve the platform for months:
- i18n opened 0fee.dev to non-English-speaking developers across 15 languages
- The SDK rewrites gave developers a polished, reliable integration experience
- The currency bug fix eliminated an entire class of display errors
- PDF receipts provided the paper trail that African merchants need for accounting
Not a bad Christmas present for a platform that was 15 days old.
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.