Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ NOMBA_PARENT_ACCOUNT_ID=
NOMBA_CLIENT_ID=
NOMBA_CLIENT_SECRET=

# Sandbox connectivity checks (POST /v1/sandbox/nomba/*) — always used
# instead of the vars above, regardless of what those point at. Lets you
# verify the Nomba integration works even when NOMBA_CLIENT_ID/SECRET above
# have been switched to live credentials.
NOMBA_SANDBOX_BASE_URL=https://sandbox.nomba.com
NOMBA_TEST_CLIENT_ID=
NOMBA_TEST_CLIENT_SECRET=

# Inbound webhook signature verification (Nomba → Somba)
WEBHOOK_SIGNING_SECRET=

Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,13 @@ It shows the entry points, the outbox, relay shards, event queues, workers, the

- [PRD.md](./PRD.md) describes the product in plain English
- [docs/](./docs/) contains the supporting documentation pages
- [somba/](./somba/) is the placeholder Python package structure
- [tests/](./tests/) is the placeholder test structure
- [scripts/](./scripts/) contains developer helper stubs
- [somba/](./somba/) is the FastAPI application: API routers, background workers (charge, recovery, reconciliation sweep, verify pass), the Nomba client, and the Alembic migrations
- [tests/](./tests/) is the real test suite (unit + integration)
- [scripts/](./scripts/) contains developer helper scripts (topic setup, demo seeding)
- [frontend/](./frontend/) is the React + Vite docs site and landing page — includes the interactive API docs, a dashboard (email/password auth, named API keys), and a sandbox page for testing the Nomba integration without touching live credentials

## Notes
## Status

This repository is still at scaffold stage. The current files are documentation and placeholders only, so the next step is implementation once the product shape is finalized.
Somba is implemented and running. The API is live at `https://somba-jade.vercel.app`, backed by Postgres and a Redpanda-based outbox relay, with a full test suite (unit + integration) passing in CI. The docs above describe the design; the code is the current source of truth for exact request/response shapes.

> Somba by Team setld
31 changes: 30 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,42 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<title>Somba — Recurring billing infrastructure for Nomba merchants</title>
<meta
name="description"
content="Add subscriptions to your product in an afternoon. Somba handles the billing, recovery, and reconciliation on Nomba's payment rails — you handle your product."
/>

<!-- Open Graph (WhatsApp, Facebook, Telegram, LinkedIn, Slack read these) -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Somba" />
<meta property="og:url" content="https://somba-jade.vercel.app/" />
<meta property="og:title" content="Somba — Recurring billing infrastructure for Nomba merchants" />
<meta
property="og:description"
content="Add subscriptions to your product in an afternoon. Somba handles the billing, recovery, and reconciliation on Nomba's payment rails — you handle your product."
/>
<meta property="og:image" content="https://somba-jade.vercel.app/og-image.png" />
<meta property="og:image:width" content="2429" />
<meta property="og:image:height" content="1863" />
<meta property="og:image:alt" content="Somba — recurring billing infrastructure for Nomba merchants" />

<!-- Twitter / X card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Somba — Recurring billing infrastructure for Nomba merchants" />
<meta
name="twitter:description"
content="Add subscriptions to your product in an afternoon. Somba handles the billing, recovery, and reconciliation on Nomba's payment rails — you handle your product."
/>
<meta name="twitter:image" content="https://somba-jade.vercel.app/og-image.png" />

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<title>Somba — Recurring billing infrastructure for Nomba merchants</title>
</head>
<body>
<div id="root"></div>
Expand Down
Binary file added frontend/public/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Landing from './pages/Landing'
import Signup from './pages/Signup'
import Login from './pages/Login'
import ApiKeys from './pages/ApiKeys'
import Sandbox from './pages/Sandbox'
import DocsLayout from './layouts/DocsLayout'

import Introduction from './pages/docs/Introduction'
Expand Down Expand Up @@ -41,6 +42,7 @@ export default function App() {
<Route path="/signup" element={<Signup />} />
<Route path="/login" element={<Login />} />
<Route path="/api-keys" element={<ApiKeys />} />
<Route path="/sandbox" element={<Sandbox />} />

<Route path="/docs" element={<DocsLayout />}>
<Route index element={<Navigate to="/docs/introduction" replace />} />
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/components/ProfileMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ export default function ProfileMenu() {
>
API keys
</Link>
<Link
to="/sandbox"
onClick={() => setOpen(false)}
className="block px-4 py-2.5 font-mono text-[13px] text-text transition-colors hover:bg-panel-2"
>
Sandbox
</Link>
<button
type="button"
onClick={onLogout}
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ async function parseJson(res) {
}
}

async function request(path, { method = 'GET', body, auth } = {}) {
async function request(path, { method = 'GET', body, auth, idempotent } = {}) {
const headers = {}
if (body) headers['Content-Type'] = 'application/json'
if (auth) headers.Authorization = `Bearer ${auth}`
if (idempotent) headers['Idempotency-Key'] = crypto.randomUUID()

const res = await fetch(`${API_BASE}${path}`, {
method,
Expand Down Expand Up @@ -62,3 +63,16 @@ export function createApiKey(sessionToken, name) {
export function revokeApiKey(sessionToken, keyRowId) {
return request(`/v1/auth/api-keys/${keyRowId}`, { method: 'DELETE', auth: sessionToken })
}

export function sandboxCheckAuth(apiKey) {
return request('/v1/sandbox/nomba/auth', { method: 'POST', auth: apiKey, idempotent: true })
}

export function sandboxCreateVirtualAccount(apiKey, customerName) {
return request('/v1/sandbox/nomba/virtual-account', {
method: 'POST',
auth: apiKey,
idempotent: true,
body: { customer_name: customerName },
})
}
239 changes: 239 additions & 0 deletions frontend/src/pages/Sandbox.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { useState } from 'react'
import SiteNav from '../components/SiteNav'
import CodeBlock from '../components/CodeBlock'
import StatusPill from '../components/StatusPill'
import { sandboxCheckAuth, sandboxCreateVirtualAccount } from '../lib/api'

function ResultBlock({ result, error }) {
if (error) {
return (
<p className="rounded-sm border border-error/40 bg-error-dim/30 px-3 py-2 text-[13px] text-error">
{error}
</p>
)
}
if (result) {
return <CodeBlock title="Response">{JSON.stringify(result, null, 2)}</CodeBlock>
}
return null
}

async function hmacSha256Hex(secret, payload) {
const enc = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
)
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(payload))
return Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}

export default function Sandbox() {
const [apiKey, setApiKey] = useState('')
const [customerName, setCustomerName] = useState('Sandbox Test Customer')

const [authStatus, setAuthStatus] = useState('idle')
const [authResult, setAuthResult] = useState(null)
const [authError, setAuthError] = useState(null)

const [vaStatus, setVaStatus] = useState('idle')
const [vaResult, setVaResult] = useState(null)
const [vaError, setVaError] = useState(null)

const [whPayload, setWhPayload] = useState('{"type":"charge.succeeded","data":{"subscription_id":"sub_xxx"}}')
const [whSecret, setWhSecret] = useState('')
const [whSignature, setWhSignature] = useState('')
const [whResult, setWhResult] = useState(null)

async function onCheckAuth() {
setAuthStatus('loading')
setAuthError(null)
setAuthResult(null)
try {
const data = await sandboxCheckAuth(apiKey.trim())
setAuthResult(data)
} catch (err) {
setAuthError(err.message)
} finally {
setAuthStatus('idle')
}
}

async function onCreateVa() {
setVaStatus('loading')
setVaError(null)
setVaResult(null)
try {
const data = await sandboxCreateVirtualAccount(apiKey.trim(), customerName.trim())
setVaResult(data)
} catch (err) {
setVaError(err.message)
} finally {
setVaStatus('idle')
}
}

async function onVerifyWebhook() {
const expected = await hmacSha256Hex(whSecret, whPayload)
const matches = expected === whSignature.trim().toLowerCase()
setWhResult({ expected, matches })
}

return (
<div className="flex min-h-screen flex-col">
<SiteNav />

<main className="flex flex-1 justify-center px-6 py-16">
<div className="w-full max-w-[640px]">
<div className="mb-2 font-mono text-[12px] uppercase tracking-wider text-text-faint">
Sandbox
</div>
<h1 className="mb-2 font-mono text-[26px] font-medium tracking-tight text-text">
Test the Nomba integration
</h1>
<p className="mb-8 text-[14px] leading-6 text-text-muted">
These calls always use dedicated sandbox credentials against{' '}
<code className="font-mono text-settled">sandbox.nomba.com</code>, regardless of
whether your account is holding live keys. Nothing here ever touches real money.
</p>

<label className="mb-10 flex flex-col gap-1.5">
<span className="font-mono text-[12px] text-text-muted">Your API key</span>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-somba-..."
className="rounded-sm border border-line bg-panel px-3 py-2 font-mono text-[13px] text-text outline-none focus-visible:border-settled"
/>
</label>

<div className="mb-6 flex flex-col gap-4 rounded-sm border border-line bg-panel/40 p-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-[15px] font-medium text-text">Auth check</h2>
<p className="text-[13px] text-text-muted">
Confirms the sandbox credentials can issue a token.
</p>
</div>
<StatusPill kind={authResult ? 'settled' : authError ? 'error' : 'neutral'}>
{authResult ? 'ok' : authError ? 'failed' : 'untested'}
</StatusPill>
</div>
<button
type="button"
onClick={onCheckAuth}
disabled={!apiKey || authStatus === 'loading'}
className="w-fit rounded-full bg-settled px-4 py-2 font-mono text-[13px] font-medium text-ink transition-opacity hover:opacity-90 disabled:opacity-50"
>
{authStatus === 'loading' ? 'Checking…' : 'Test auth'}
</button>
<ResultBlock result={authResult} error={authError} />
</div>

<div className="mb-6 flex flex-col gap-4 rounded-sm border border-line bg-panel/40 p-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-[15px] font-medium text-text">Virtual account</h2>
<p className="text-[13px] text-text-muted">
Creates a throwaway sandbox virtual account end-to-end.
</p>
</div>
<StatusPill kind={vaResult ? 'settled' : vaError ? 'error' : 'neutral'}>
{vaResult ? 'ok' : vaError ? 'failed' : 'untested'}
</StatusPill>
</div>

<p className="rounded-sm border border-pending/40 bg-pending-dim/20 px-3 py-2 text-[12px] leading-5 text-text-muted">
Nomba caps sandbox accounts at 2 virtual accounts total per account holder — once
that&rsquo;s used up, this will fail with a real Nomba error even though nothing is
broken. That&rsquo;s expected, not a bug.
</p>

<label className="flex flex-col gap-1.5">
<span className="font-mono text-[12px] text-text-muted">Customer name</span>
<input
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
className="rounded-sm border border-line bg-panel px-3 py-2 text-[14px] text-text outline-none focus-visible:border-settled"
/>
</label>

<button
type="button"
onClick={onCreateVa}
disabled={!apiKey || !customerName || vaStatus === 'loading'}
className="w-fit rounded-full bg-settled px-4 py-2 font-mono text-[13px] font-medium text-ink transition-opacity hover:opacity-90 disabled:opacity-50"
>
{vaStatus === 'loading' ? 'Creating…' : 'Create test virtual account'}
</button>
<ResultBlock result={vaResult} error={vaError} />
</div>

<div className="flex flex-col gap-4 rounded-sm border border-line bg-panel/40 p-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-[15px] font-medium text-text">Webhook signature</h2>
<p className="text-[13px] text-text-muted">
Checks a payload + secret against a signature entirely in your browser — no
Nomba call, no quota.
</p>
</div>
{whResult && (
<StatusPill kind={whResult.matches ? 'settled' : 'error'}>
{whResult.matches ? 'matches' : "doesn't match"}
</StatusPill>
)}
</div>

<label className="flex flex-col gap-1.5">
<span className="font-mono text-[12px] text-text-muted">Payload (raw body)</span>
<textarea
value={whPayload}
onChange={(e) => setWhPayload(e.target.value)}
rows={3}
className="rounded-sm border border-line bg-panel px-3 py-2 font-mono text-[12px] text-text outline-none focus-visible:border-settled"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="font-mono text-[12px] text-text-muted">Webhook secret</span>
<input
type="password"
value={whSecret}
onChange={(e) => setWhSecret(e.target.value)}
className="rounded-sm border border-line bg-panel px-3 py-2 font-mono text-[13px] text-text outline-none focus-visible:border-settled"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="font-mono text-[12px] text-text-muted">Signature to check</span>
<input
value={whSignature}
onChange={(e) => setWhSignature(e.target.value)}
placeholder="hex digest from X-Somba-Signature"
className="rounded-sm border border-line bg-panel px-3 py-2 font-mono text-[13px] text-text outline-none focus-visible:border-settled"
/>
</label>

<button
type="button"
onClick={onVerifyWebhook}
disabled={!whPayload || !whSecret}
className="w-fit rounded-full bg-settled px-4 py-2 font-mono text-[13px] font-medium text-ink transition-opacity hover:opacity-90 disabled:opacity-50"
>
Verify signature
</button>

{whResult && (
<CodeBlock title="Computed HMAC-SHA256">{whResult.expected}</CodeBlock>
)}
</div>
</div>
</main>
</div>
)
}
2 changes: 2 additions & 0 deletions somba/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from somba.api.middleware.auth import get_current_merchant
from somba.api.middleware.idempotency import IdempotencyMiddleware
from somba.api.plans import router as plans_router
from somba.api.sandbox import router as sandbox_router
from somba.api.subscriptions import router as subscriptions_router
from somba.api.webhooks import router as webhooks_router
from somba.db.models import Merchant
Expand All @@ -39,6 +40,7 @@
app.include_router(invoices_router)
app.include_router(events_router)
app.include_router(metrics_router)
app.include_router(sandbox_router)


@app.on_event("startup")
Expand Down
Loading
Loading