cat <filepath> on server and paste outputProject: Yealin Billing Platform
Owner: Christopher Olsen (Crip)
Company: Yealin Communications Pty Ltd
ABN: 43 663 151 835
Purpose: Custom telecom billing platform replacing BillingBooth
Status: LIVE at https://billing.yealin.com.au
GitHub: https://github.com/ounyai/yealin-billing (PRIVATE)
Wiki: https://wiki.yealin.com.au
Twilio API/CSV → CDR normaliser → CLI matcher → destination classifier
→ pricing engine (live FX rate + markup) → billing run
→ WeasyPrint PDF invoice → Stripe payment → cPanel SMTP email
→ customer portal self-service
| Role | Hostname | IP | OS | User |
|---|---|---|---|---|
| Development | yealin-dev.bylaw.com.au | 10.0.0.41 (LAN) | Debian 13.5 | crip |
| Production | billing.yealin.com.au | 51.161.136.89 (OVH) | Debian 13.5 | crip |
| Proxmox | nu.bylaw.com.au | LAN | Proxmox | — |
| cPanel/SMTP | aum.bylaw.com.au | 51.161.193.85 | cPanel | — |
| Wiki | wiki.yealin.com.au | 51.161.136.89 (Docker) | Wiki.js 2 | — |
App path: /var/www/yealinbilling
DB name: yealinbilling (PostgreSQL)
Wiki path: /opt/wikijs/docker-compose.yml
Wiki API: https://wiki.yealin.com.au/graphql
cd /var/www/yealinbilling
source venv/bin/activate
export DJANGO_SETTINGS_MODULE=config.settings.development
cd /var/www/yealinbilling
source venv/bin/activate
export DJANGO_SETTINGS_MODULE=config.settings.production
The venv stays active for the entire terminal session. You must run these three commands every time you open a new SSH session.
cd /var/www/yealinbilling/frontend && npm run build
cd /var/www/yealinbilling
sudo systemctl restart gunicorn
git add .
git commit -m "Session X: description"
git push origin main
cd /var/www/yealinbilling
git stash && git pull origin main && git stash drop
python manage.py migrate
python manage.py check
python manage.py collectstatic --noinput
cd frontend && npm run build
cd /var/www/yealinbilling
sudo systemctl restart gunicorn celery celerybeat
python manage.py changepassword admin
OS: Debian 13.5 — NEVER Ubuntu
PDF: WeasyPrint 69.0 — NEVER Playwright/ReportLab
Email: cPanel SMTP aum.bylaw.com.au:465 SSL — NEVER SendGrid
GoCardless: DEFERRED — do not implement
Payments: Stripe only
PDF charts: Inline SVG ONLY — NEVER JS charts in PDFs
App path: /var/www/yealinbilling on BOTH servers
Frontend: npm run build → staticfiles/frontend/
DB name: yealinbilling
Invoice nums: YC-YYYY-NNNN format
Branding: #F01354 red · #0D0D0D near-black · #FFFFFF white
Logo: static/images/yealin-logo.png
Billable: BOTH CustomerCLI.billable AND CallRecord.billable = True
EMAIL_BACKEND: smtp.EmailBackend BOTH dev and prod — NEVER console
Portal auth: @authentication_classes([]) MANDATORY on ALL portal views
Workflow: ALWAYS dev first → test → commit → pull to prod
cat > heredoc: ALWAYS verify file contents after writing
core — Setting, AuditLog, WebhookEvent, TimeStampedModel, FX tasks
customers — Customer, CustomerGroup, CustomerCLI
cdr — CallRecord, pricing engine, Twilio sync
products — Category, Tariff, Product, Destination, RateCard, ServiceCharge
billing — BillingRun, Invoice, InvoiceLineItem, PDF, email
payments — Payment, Stripe client, webhook handler
portal — CustomerPortalUser
api — All views, URL routing
COMPANY_NAME, COMPANY_ABN, COMPANY_ADDRESS, COMPANY_PHONE,
COMPANY_EMAIL, COMPANY_WEBSITE, DEFAULT_PAYMENT_TERMS (14),
DEFAULT_GST_RATE (10), USD_TO_AUD_RATE, USD_TO_AUD_UPDATED_AT,
FX_MARKUP_PERCENT
cli CharField # E.164 e.g. +61400000000
direction CharField # inbound/outbound/both
billable BooleanField(default=True)
UNIQUE: (cli, direction)
# NOT linked to Django User — completely separate auth
set_password(raw) / check_password(raw)
# Portal JWT: is_portal=True, customer_id, portal_user_id, customer_name
# Token lifetime: 8 hours
invoice_number # YC-YYYY-NNNN
status # draft/sent/paid/overdue/void
amount_outstanding = total_aud - amount_paid_aud # PROPERTY
stripe_checkout_session_id # field exists on model
customer # FK — REQUIRED, not nullable — always pass customer=invoice.customer
billable BooleanField # set from CustomerCLI.billable at import time
invoiced BooleanField # True once included in a billing run
| App | Migrations |
|---|---|
| core | 0001_initial, 0002_setting |
| customers | 0001_initial, 0002_billable_cli |
| cdr | 0001_initial, 0002_billable_callrecord |
| billing | 0001_initial, 0002_stripe_checkout |
| payments | 0001_initial, 0002_stripe_fields |
| portal | 0001_initial |
| products | 0001_initial |
const token = localStorage.getItem('access_token') // or 'portal_token'
const res = await fetch('/api/invoices/' + id + '/download/', {
headers: { Authorization: 'Bearer ' + token }
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = invoiceNumber + '.pdf'; a.click()
URL.revokeObjectURL(url)
@api_view(['GET'])
@authentication_classes([]) # bypasses DRF JWT check
@permission_classes([AllowAny])
def my_portal_view(request):
customer_id, err = get_portal_customer(request)
if err: return err
from apps.portal.models import CustomerPortalUser
from apps.customers.models import Customer
customer = Customer.objects.get(name='Customer Name')
user = CustomerPortalUser(customer=customer, email='email@domain.com', active=True)
user.set_password('password')
user.save()
POST /api/auth/token/ — Admin login
GET /api/dashboard/stats/ — Dashboard data
GET /api/fx/ — FX rate info
POST /api/fx/markup/ — Set markup %
POST /api/fx/refresh/ — Refresh live rate
GET /api/reports/revenue/?months=12
GET /api/reports/customers/
GET /api/reports/outstanding/
GET /api/reports/cdr-export/
GET/POST /api/settings/
CRUD /api/destinations/ + POST seed-australian/
CRUD /api/products/, categories/, tariffs/
CRUD /api/rate-cards/, service-charges/
CRUD /api/customers/, customer-groups/, customer-clis/
GET /api/cdr/ + summary/ + POST reprocess/
POST /api/billing-runs/execute/ + GET /api/billing-runs/
GET /api/invoices/ + download/ + POST send/ + regenerate-pdf/
GET /api/payments/ + POST checkout/ + manual/
POST /api/webhooks/stripe/ — no auth, Stripe sig verify
POST /api/portal/auth/token/
GET /api/portal/invoices/ + {id}/ + download/ + POST pay/
GET /api/portal/usage/
# Celery Beat — runs every hour
@shared_task(name='core.refresh_fx_rate')
def refresh_fx_rate():
# fetches from https://api.exchangerate-api.com/v4/latest/USD
# stores in core_setting: USD_TO_AUD_RATE, USD_TO_AUD_UPDATED_AT
# Pricing engine
def get_usd_to_aud_rate():
live = float(Setting.objects.get(key='USD_TO_AUD_RATE').value)
markup = float(Setting.objects.get(key='FX_MARKUP_PERCENT').value)
return Decimal(str(live * (1 + markup / 100)))
Backend: smtp.EmailBackend (BOTH dev + prod)
Host: aum.bylaw.com.au port 465 SSL
From: billing@yealin.com.au
SPF: includes 202.47.178.140 (dev) and 51.161.136.89 (prod)
Mode: Test (sk_test_... / pk_test_...)
Prod webhook: https://billing.yealin.com.au/api/webhooks/stripe/
Dev webhook: Stripe CLI on Windows (secret changes each restart)
Test card: 4242 4242 4242 4242
/api/portal/ → Gunicorn
/api/ → Gunicorn
/admin/ → Gunicorn
/static/ → staticfiles/
/media/ → media/
/* → React SPA (index.html)
# /portal/* is NOT proxied to Django — served by React SPA
| # | Summary | Commit |
|---|---|---|
| 1 | Django scaffold, models, PostgreSQL, Nginx, Gunicorn, SMTP, Twilio | 793fffc |
| 2 | CDR pipeline, CSV+API import, CLI matching, pricing, Celery | 7ea7448 |
| 3 | React frontend, login, dashboard, sidebar, JWT, SPA | bb2371b |
| 4 | Products, destinations, rate cards API + UI | e60ae8e |
| 5 | Customer UI, CDR matching, billable flag | 5e44590 |
| 6 | Billing engine, WeasyPrint PDF, invoices UI | 13b1de5 |
| 7 | Stripe payments, checkout, webhook, auto-paid | fdb306d |
| 8 | Customer portal, invoice email, SMTP fix, SPF | a924390 |
| 9 | Reports, analytics, dashboard live, settings, CDR export | 0bb738c |
| 10 | Production deployment, SSL, services, go-live | — |
| 11 | Live FX rate + markup, Celery refresh, pricing engine | 997ab96 |
| 12 | Wiki.js Docker, SSL, GraphQL API, Claude direct management | — |
| Issue | Detail |
|---|---|
| Stripe dev webhook | Changes each stripe listen restart — update .env + restart gunicorn |
| Portal @authentication_classes | MANDATORY — without it returns 401 |
| cat > heredoc | Verify with grep after writing |
| Production git pull | git stash → pull → stash drop if local changes |
| Gunicorn socket | Must be srwxrwx--- crip:www-data |
| EMAIL_BACKEND | Must be smtp — never console in deployed env |
| Ubuntu + OVH | AVOID — use Debian 13.5 |
| Item | Bitwarden Entry |
|---|---|
| Dev SSH | crip@10.0.0.41 |
| Prod SSH | crip@51.161.136.89 |
| Django admin (dev) | Yealin Billing Admin — dev |
| Django admin (prod) | Yealin Billing Admin — production |
| Portal login | co@aus.co |
| PostgreSQL | Yealin Billing — PostgreSQL DB |
| Twilio API Key | Twilio API Key — yealin-billing-dev |
| SMTP | Yealin Billing — SMTP billing@yealin.com.au |
| GitHub PAT | GitHub PAT — yealin-billing-servers |
| Stripe (test) | Stripe — yealin-billing-dev |
| Wiki.js API Key | Wiki.js API Key — Claude |
[State your session goal here when starting a new conversation]
Example: Session 13: [describe what you want to build]