Build an Invoicing System
Store invoices as drops, run business logic in functions, generate PDFs server-side — a complete invoicing app with zero infrastructure.
This tutorial walks through building a real invoicing system entirely on InfiniteOcean. No database, no server, no deploy pipeline. Drops store the data, functions handle the logic, the View Service hosts the frontend, and cron runs scheduled jobs. The result is a production invoicing tool built on a handful of API calls.
1Architecture
The app has four parts, all stored as drops:
| What | Route | How |
|---|---|---|
| Frontend (HTML, JS, CSS) | invo | Drops served by View Service |
| API function | fn/invo/api | Python function handling all CRUD |
| PDF generator | fn/invo/pdf | Separate function, called by API |
| Overdue checker | fn/invo/overdue | Cron-triggered function |
Every piece of data — accounts, entities, clients, invoices, timeline events — lives in drops under the invo/ route prefix. No external database.
2Set Up Authentication
InfiniteOcean has built-in magic link auth. The frontend sends an email, the user clicks a link, and they get back an Ocean key tied to their identity.
// Send magic link
const res = await fetch('https://api.infiniteocean.io/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: userEmail,
callback_url: window.location.origin + window.location.pathname,
app_name: 'Invo',
subject: 'Sign in to Invo'
})
});
When the user clicks the link, they return with a ?token= parameter. Verify it to get their key:
const { key } = await fetch(
`https://api.infiniteocean.io/auth/verify?token=${token}`
).then(r => r.json());
// Store the key — this is their session
localStorage.setItem('invo_key', key);
From here, GET /auth/me with the key header returns their email. The API function maps this to an account.
3The API Function
The entire backend is one Python function deployed as a drop. It receives a JSON body with an action field and routes to the right handler.
import json
import hashlib
def main(ocean, request):
action = request.get('action')
auth_key = request.get('auth_key')
if action == 'account.init':
return account_init(ocean, request)
# Authenticate
account = get_account_by_key(ocean, auth_key)
if not account:
return {'error': 'Not authenticated'}
account_id = account['account_id']
handlers = {
'entity.list': entity_list,
'entity.create': entity_create,
'client.list': client_list,
'client.create': client_create,
'invoice.list': invoice_list,
'invoice.create': invoice_create,
'invoice.finalize': invoice_finalize,
# ... more actions
}
handler = handlers.get(action)
if not handler:
return {'error': f'Unknown action: {action}'}
return handler(ocean, account_id, request)
Deploy it:
curl -X POST https://api.infiniteocean.io/drop \
-H "Content-Type: application/json" \
-H "X-Ocean-Key: YOUR_KEY" \
-d "$(jq -n --arg code "$(cat api.py)" \
'{route: "fn/invo/api", key: "main", payload: $code}')"
Make it publicly callable (the function handles its own auth):
curl -X POST https://api.infiniteocean.io/drop \
-H "Content-Type: application/json" \
-H "X-Ocean-Key: YOUR_KEY" \
-d '{
"route": "fn/invo/api",
"key": "_config",
"payload": "{\"execute\":\"public\"}"
}'
Now anyone can call it via POST https://api.infiniteocean.io/run/fn/invo/api.
_config entity with execute: "public" makes the function callable without an Ocean key. The function itself verifies the caller's auth_key internally, so unauthenticated requests get rejected at the application level.
4Storing Data as Drops
Every entity is a drop with a JSON payload. Writing is a POST to /drop:
def invoice_create(ocean, account_id, request):
invoice_id = request.get('invoice_id')
invoice = {
'id': invoice_id,
'account_id': account_id,
'entity_id': request.get('entity_id'),
'client_id': request.get('client_id'),
'number': 'DRAFT',
'currency': request.get('currency', 'EUR'),
'lines': request.get('lines', []),
'total': request.get('total', 0),
'status': 'draft',
'created_at': now_iso()
}
ocean.write('invo/invoices', json.dumps(invoice), key=invoice_id)
return {'invoice_id': invoice_id}
- ocean.write(route, payload, key)
- Creates a drop. The key makes it an entity — a latest-version record you can query.
- invo/invoices
- The route. All invoices are grouped here, queryable via SQL.
5Reading Data with SQL
InfiniteOcean supports SQL queries over drops. This is how the app lists invoices for an account:
def list_by_account(ocean, collection, account_id):
r = ocean.sql(
f"SELECT * FROM 'invo/{collection}' "
f"WHERE account_id = '{account_id}' "
f"ORDER BY created_at DESC LIMIT 500"
)
if not r or r.get('row_count', 0) == 0:
return []
cols = r['columns']
items = []
for row in r['rows']:
item = {}
for i, col in enumerate(cols):
if col != 'vector':
item[col] = row[i]
items.append(item)
return items
FROM 'invo/invoices'. Without quotes, the parser treats the slash as division.
Reading a single entity by key:
def sql_read(ocean, table, key):
r = ocean.sql(f"SELECT * FROM '{table}' WHERE key = '{key}' LIMIT 1")
if not r or r.get('row_count', 0) == 0:
return None
cols = r['columns']
row = r['rows'][0]
return {col: row[i] for i, col in enumerate(cols) if col != 'vector'}
6Invoice Lifecycle
An invoice moves through states: draft → finalized → sent → paid (or voided). Finalizing assigns a sequential number and locks the invoice:
def invoice_finalize(ocean, account_id, request):
invoice = read_entity(ocean, 'invoices', request.get('invoice_id'))
if invoice.get('status') != 'draft':
return {'error': 'Only draft invoices can be finalized'}
# Assign sequential number from entity counter
entity = read_entity(ocean, 'entities', invoice.get('entity_id'))
seq = (entity.get('invoice_seq') or 0) + 1
year = datetime.now(timezone.utc).year
number = f'INV-{year}-{str(seq).zfill(3)}'
entity['invoice_seq'] = seq
ocean.write('invo/entities', json.dumps(entity), key=entity_id)
invoice['number'] = number
invoice['status'] = 'finalized'
ocean.write('invo/invoices', json.dumps(invoice), key=invoice_id)
return {'ok': True, 'number': number}
Every state change writes a timeline event to a per-invoice event stream:
def add_event(ocean, invoice_id, event_type):
event = json.dumps({
'type': event_type,
'at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z')
})
ocean.write(f'invo/invoice-events/{invoice_id}', event)
These accumulate as drops (no key = append-only), building an audit trail.
7PDF Generation
The PDF generator is its own function at fn/invo/pdf. It builds a real PDF using only Python stdlib — no third-party libraries.
def main(ocean, request):
inv = request.get('invoice', {})
entity = request.get('entity', {})
client = request.get('client', {})
filename = request.get('filename', 'invoice.pdf')
pdf_bytes = generate_pdf(inv, entity, client)
# Upload as blob
result = ocean.upload('invo/pdfs', pdf_bytes, filename)
blob_id = result.get('drop_id')
return {'blob_id': blob_id, 'filename': filename}
The API calls it on finalize via ocean.run():
pdf_result = ocean.run('fn/invo/pdf', {
'invoice': invoice,
'entity': entity,
'client': client,
'filename': f'{number}.pdf'
})
The PDF is stored as a blob. Download it at GET /blob/{blob_id} — a direct link with proper content-type headers. No intermediate step, no print dialog.
io.BytesIO, struct, and string formatting. About 200 lines for a clean, professional invoice layout.
8Hosting the Frontend
The frontend is vanilla JavaScript — no build step. Deploy every file as a drop under the invo route:
# Deploy index.html as the default page (key: "index")
curl -X POST https://api.infiniteocean.io/drop \
-H "Content-Type: application/json" \
-H "X-Ocean-Key: YOUR_KEY" \
-d "$(jq -n --arg payload "$(cat src/index.html)" \
'{route: "invo", key: "index", payload: $payload}')"
# Deploy JS and CSS
curl -X POST https://api.infiniteocean.io/drop \
-H "Content-Type: application/json" \
-H "X-Ocean-Key: YOUR_KEY" \
-d "$(jq -n --arg payload "$(cat src/app.js)" \
'{route: "invo", key: "app.js", payload: $payload}')"
The View Service at view.infiniteocean.io serves these directly:
| URL | Resolves to |
|---|---|
view.infiniteocean.io/invo/ | route invo, key index |
view.infiniteocean.io/invo/app.js | route invo, key app.js |
view.infiniteocean.io/invo/style.css | route invo, key style.css |
Content-type is detected from the key extension: .js serves as application/javascript, .css as text/css. The key index is the default for trailing-slash URLs.
9Scheduled Jobs with Cron
A daily cron job detects overdue invoices:
def main(ocean, request):
from datetime import datetime, timezone
today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
r = ocean.sql(
"SELECT * FROM 'invo/invoices' "
"WHERE status IN ('sent', 'finalized', 'viewed') "
f"AND due_date < '{today}' LIMIT 1000"
)
if not r or r.get('row_count', 0) == 0:
return {'ok': True, 'invoices_marked_overdue': 0}
marked = 0
for row in r['rows']:
# ... mark as overdue, write timeline event
marked += 1
return {'ok': True, 'invoices_marked_overdue': marked}
Deploy the function, then set up the cron:
curl -X POST https://api.infiniteocean.io/cron \
-H "Content-Type: application/json" \
-H "X-Ocean-Key: YOUR_KEY" \
-d '{
"name": "invo-overdue",
"route": "fn/invo/overdue",
"schedule": "0 7 * * *",
"body": {},
"enabled": true
}'
Runs every morning at 7 AM UTC. Standard 5-field cron syntax.
10VAT Calculation and VIES Verification
The app handles EU VAT rules client-side: domestic VAT, reverse charge for intra-EU B2B, zero-rate for exports. VAT numbers are verified against the EU VIES service from within a function:
def client_vat_verify(ocean, account_id, request):
client = read_entity(ocean, 'clients', request.get('client_id'))
vat_number = client.get('vat_number', '')
country_code = vat_number[:2].upper()
number = vat_number[2:]
import urllib.request
req_data = json.dumps({
'countryCode': country_code,
'vatNumber': number
}).encode('utf-8')
req = urllib.request.Request(
'https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number',
data=req_data,
headers={'Content-Type': 'application/json'}
)
resp = urllib.request.urlopen(req, timeout=10)
result = json.loads(resp.read().decode('utf-8'))
client['vat_verified'] = result.get('valid', False)
ocean.write('invo/clients', json.dumps(client), key=client_id)
return {'vat_valid': client['vat_verified']}
Functions can call external APIs with urllib.request — no special setup needed.
11What You End Up With
A complete invoicing system:
Magic Link Auth
No passwords. Email-based sign in with session keys.
Multi-Entity
Invoice from different companies under one account.
Client Management
With EU VAT verification via VIES.
Invoice Editor
Autosaving drafts, line items, VAT calculation.
PDF Generation
Real PDFs via server-side Python, stored as blobs.
Full Lifecycle
Draft → finalized → sent → paid/voided.
Timeline
Append-only event log per invoice.
Overdue Detection
Daily cron job marks overdue invoices.
Zero Infrastructure
Everything is drops, functions, and blobs.
The entire deploy is a bash script that POSTs files to /drop. Update any file and it's live instantly.
Platform Features Used
| Feature | How it's used |
|---|---|
| Drops | All data storage (invoices, clients, entities, events) |
| Entities (keyed drops) | Latest state for each record |
| SQL | Querying and listing data |
| Functions | API logic, PDF generation, overdue detection |
ocean.run() | API function calling PDF function |
ocean.upload() | Storing generated PDFs as blobs |
| Blobs | PDF file storage with direct download URLs |
| View Service | Hosting the frontend (HTML, JS, CSS) |
| Magic Link Auth | User authentication |
| Cron | Scheduled overdue invoice detection |