feat(v2)(ui): operator-side LP UI matches the new dca_lp authority split

Operator no longer chooses the LP's wallet / DCA mode / autoforward —
those belong to the LP, written via satmachineclient. The Add LP /
Edit LP dialogs reduce to (machine, user_id, optional username,
status). The clients table loses the wallet / mode / autoforward
columns and gains an "Onboarded" column showing whether the LP has a
`dca_lp` row yet (server-side LEFT JOIN; `DcaClient.lp_onboarded`).

Deposit creation gate (the structural enforcement of "must onboard
first"):
- Picker annotates each LP option with "— pending onboarding" and
  disables un-onboarded LP rows.
- Selecting an un-onboarded LP shows an inline deep-orange banner
  explaining the LP needs to open satmachineclient once.
- The Record button is `:disable`d in that state. The backend
  refuses with HTTP 422 anyway (see previous commit) — UI is just
  the first line of feedback.

Backend wiring:
- `DcaClient` model gains `lp_onboarded: bool = False`, populated
  at SELECT time via a shared `_CLIENT_SELECT` / `_CLIENT_FROM`
  fragment that LEFT JOINs `dca_lp` on `user_id`. All four list/
  single-row read paths use it: by-id, by-(machine,user), by-machine,
  by-operator, by-user. No extra round-trip per row.
- CSV export drops the removed columns; adds `lp_onboarded`.

All 86 unit tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-16 10:12:23 +02:00
commit cfad4e341c
4 changed files with 93 additions and 103 deletions

44
crud.py
View file

@ -201,9 +201,23 @@ async def create_dca_client(data: CreateDcaClientData) -> DcaClient:
return client
# Shared SELECT fragment: client columns plus the LP-onboarded flag
# computed via LEFT JOIN on dca_lp. Returned as `lp_onboarded` (boolean
# 0/1 in SQLite, which Pydantic coerces to bool on the DcaClient model).
_CLIENT_SELECT = """
c.id, c.machine_id, c.user_id, c.username, c.status,
c.created_at, c.updated_at,
(lp.user_id IS NOT NULL) AS lp_onboarded
"""
_CLIENT_FROM = (
"satoshimachine.dca_clients c "
"LEFT JOIN satoshimachine.dca_lp lp ON lp.user_id = c.user_id"
)
async def get_dca_client(client_id: str) -> Optional[DcaClient]:
return await db.fetchone(
"SELECT * FROM satoshimachine.dca_clients WHERE id = :id",
f"SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM} WHERE c.id = :id",
{"id": client_id},
DcaClient,
)
@ -213,9 +227,9 @@ async def get_dca_client_for_machine_user(
machine_id: str, user_id: str
) -> Optional[DcaClient]:
return await db.fetchone(
"""
SELECT * FROM satoshimachine.dca_clients
WHERE machine_id = :machine_id AND user_id = :user_id
f"""
SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM}
WHERE c.machine_id = :machine_id AND c.user_id = :user_id
""",
{"machine_id": machine_id, "user_id": user_id},
DcaClient,
@ -224,10 +238,10 @@ async def get_dca_client_for_machine_user(
async def get_dca_clients_for_machine(machine_id: str) -> List[DcaClient]:
return await db.fetchall(
"""
SELECT * FROM satoshimachine.dca_clients
WHERE machine_id = :machine_id
ORDER BY created_at DESC
f"""
SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM}
WHERE c.machine_id = :machine_id
ORDER BY c.created_at DESC
""",
{"machine_id": machine_id},
DcaClient,
@ -237,9 +251,9 @@ async def get_dca_clients_for_machine(machine_id: str) -> List[DcaClient]:
async def get_dca_clients_for_operator(operator_user_id: str) -> List[DcaClient]:
"""All clients across every machine this operator owns."""
return await db.fetchall(
"""
SELECT c.*
FROM satoshimachine.dca_clients c
f"""
SELECT {_CLIENT_SELECT}
FROM {_CLIENT_FROM}
JOIN satoshimachine.dca_machines m ON m.id = c.machine_id
WHERE m.operator_user_id = :uid
ORDER BY c.created_at DESC
@ -252,10 +266,10 @@ async def get_dca_clients_for_operator(operator_user_id: str) -> List[DcaClient]
async def get_dca_clients_for_user(user_id: str) -> List[DcaClient]:
"""LP cross-operator view — every machine this LP is registered at."""
return await db.fetchall(
"""
SELECT * FROM satoshimachine.dca_clients
WHERE user_id = :user_id
ORDER BY created_at DESC
f"""
SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM}
WHERE c.user_id = :user_id
ORDER BY c.created_at DESC
""",
{"user_id": user_id},
DcaClient,

View file

@ -102,6 +102,10 @@ class DcaClient(BaseModel):
status: str
created_at: datetime
updated_at: datetime
# Computed at SELECT time via LEFT JOIN on dca_lp. Lets the operator
# UI render "pending onboarding" badges and disable deposit creation
# without a second round-trip per row.
lp_onboarded: bool = False
class UpdateDcaClientData(BaseModel):

View file

@ -141,18 +141,21 @@ window.app = Vue.createApp({
},
clientsTable: {
// Wallet / mode / autoforward dropped — those are LP-controlled
// via satmachineclient, not the operator's concern. `onboarded`
// surfaces the dca_lp existence flag (lp_onboarded) so operators
// can see at a glance which LPs still need to register before
// deposits can be recorded against them.
columns: [
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
{name: 'username', label: 'LP', field: 'username', align: 'left'},
{name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'},
{name: 'dca_mode', label: 'Mode', field: 'dca_mode', align: 'left'},
{name: 'onboarded', label: 'Onboarded', field: 'lp_onboarded', align: 'center'},
{
name: 'remaining_balance',
label: 'Balance',
field: 'remaining_balance',
align: 'right'
},
{name: 'autoforward', label: '→', field: 'autoforward_enabled', align: 'center'},
{name: 'status', label: 'Status', field: 'status', align: 'left'},
{name: 'actions', label: '', field: 'id', align: 'right'}
],
@ -257,11 +260,24 @@ window.app = Vue.createApp({
}))
},
depositClientOptions() {
// Annotate each LP option with onboarding state so the operator
// sees at-pick time which LPs can accept deposits. We don't hide
// un-onboarded LPs — the operator might want to know they exist
// and chase them — but submission is gated below by
// `selectedDepositClient.lp_onboarded`.
return this.clients.map(c => ({
label: `${c.username || this.shortId(c.user_id)} @ ${this.machineNameById(c.machine_id)}`,
value: c.id
label:
`${c.username || this.shortId(c.user_id)} @ ` +
`${this.machineNameById(c.machine_id)}` +
(c.lp_onboarded ? '' : ' — pending onboarding'),
value: c.id,
disable: !c.lp_onboarded
}))
},
selectedDepositClient() {
const id = this.depositDialog.data.client_id
return id ? this.clients.find(c => c.id === id) : null
},
worklistBuckets() {
return [
{
@ -559,9 +575,9 @@ window.app = Vue.createApp({
})
this._downloadCsv(
'clients.csv',
['id', 'machine_id', 'machine_name', 'user_id', 'wallet_id',
'username', 'dca_mode', 'status', 'autoforward_enabled',
'autoforward_ln_address', 'total_deposits', 'total_payments',
['id', 'machine_id', 'machine_name', 'user_id',
'username', 'lp_onboarded', 'status',
'total_deposits', 'total_payments',
'remaining_balance', 'balance_currency', 'created_at'],
rows
)
@ -881,12 +897,7 @@ window.app = Vue.createApp({
id: client.id,
machine_id: client.machine_id,
user_id: client.user_id,
wallet_id: client.wallet_id,
username: client.username || '',
dca_mode: client.dca_mode,
fixed_mode_daily_limit: client.fixed_mode_daily_limit,
autoforward_enabled: !!client.autoforward_enabled,
autoforward_ln_address: client.autoforward_ln_address || '',
status: client.status
}
this.clientDialog.show = true
@ -1277,15 +1288,13 @@ window.app = Vue.createApp({
},
_emptyClientForm() {
// Operator-side LP enrolment is just (machine, user, optional
// display name). Wallet / mode / autoforward are LP-controlled
// via satmachineclient — operator can't pick or change them.
return {
machine_id: null,
user_id: '',
wallet_id: '',
username: '',
dca_mode: 'flow',
fixed_mode_daily_limit: null,
autoforward_enabled: false,
autoforward_ln_address: '',
status: 'active'
}
},
@ -1294,30 +1303,13 @@ window.app = Vue.createApp({
return {
machine_id: d.machine_id,
user_id: (d.user_id || '').trim(),
wallet_id: (d.wallet_id || '').trim(),
username: (d.username || '').trim() || null,
dca_mode: d.dca_mode || 'flow',
fixed_mode_daily_limit:
d.dca_mode === 'fixed' && d.fixed_mode_daily_limit
? Number(d.fixed_mode_daily_limit) : null,
autoforward_enabled: !!d.autoforward_enabled,
autoforward_ln_address:
d.autoforward_enabled && d.autoforward_ln_address
? d.autoforward_ln_address.trim() : null
username: (d.username || '').trim() || null
}
},
_cleanClientUpdate(d) {
return {
username: (d.username || '').trim() || null,
dca_mode: d.dca_mode,
fixed_mode_daily_limit:
d.dca_mode === 'fixed' && d.fixed_mode_daily_limit
? Number(d.fixed_mode_daily_limit) : null,
autoforward_enabled: !!d.autoforward_enabled,
autoforward_ln_address:
d.autoforward_enabled && d.autoforward_ln_address
? d.autoforward_ln_address.trim() : null,
status: d.status
}
},

View file

@ -222,13 +222,21 @@
<q-td key="username">
<span v-text="props.row.username || shortId(props.row.user_id)"></span>
</q-td>
<q-td key="wallet_id">
<code :style="{fontSize: '0.8em'}"
v-text="shortId(props.row.wallet_id)"></code>
</q-td>
<q-td key="dca_mode">
<q-badge :color="props.row.dca_mode === 'flow' ? 'blue' : 'purple'"
:label="props.row.dca_mode"></q-badge>
<q-td key="onboarded">
<q-icon v-if="props.row.lp_onboarded"
name="check_circle" color="green" size="sm">
<q-tooltip>
LP has onboarded via satmachineclient.
Deposits and DCA distributions can proceed.
</q-tooltip>
</q-icon>
<q-icon v-else name="pending" color="deep-orange" size="sm">
<q-tooltip>
LP hasn't installed/opened satmachineclient yet.
Deposits will be refused until they register and
select a DCA wallet.
</q-tooltip>
</q-icon>
</q-td>
<q-td key="remaining_balance" class="text-right">
<span v-if="clientBalances[props.row.id]"
@ -236,18 +244,6 @@
v-text="formatFiat(clientBalances[props.row.id].remaining_balance, clientBalances[props.row.id].currency)"></span>
<span v-else :style="{opacity: 0.5}"></span>
</q-td>
<q-td key="autoforward">
<q-icon v-if="props.row.autoforward_enabled"
name="forward_to_inbox" color="primary" size="sm">
<q-tooltip>
Auto-forward enabled →
<span v-text="props.row.autoforward_ln_address || '(no address)'"></span>
</q-tooltip>
</q-icon>
<q-icon v-else name="forward" color="grey-5" size="sm">
<q-tooltip>Auto-forward disabled</q-tooltip>
</q-icon>
</q-td>
<q-td key="status">
<q-badge
:color="props.row.status === 'active' ? 'green' : 'grey'"
@ -1128,6 +1124,17 @@
class="q-mb-md" dense outlined
:rules="[v => !!v || 'Pick an LP']"></q-select>
<q-banner v-if="depositDialog.mode === 'add' && selectedDepositClient && !selectedDepositClient.lp_onboarded"
class="bg-deep-orange-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="pending" color="deep-orange"></q-icon>
</template>
This LP hasn't onboarded via <b>satmachineclient</b> yet, so
their DCA wallet isn't configured. Ask them to open the
satmachineclient extension once and the deposit will be
accepted next time.
</q-banner>
<q-input v-model.number="depositDialog.data.amount"
label="Amount (fiat)"
type="number" step="0.01" min="0"
@ -1148,6 +1155,7 @@
<q-btn color="primary"
:label="depositDialog.mode === 'add' ? 'Record' : 'Save'"
:loading="depositDialog.saving"
:disable="depositDialog.mode === 'add' && (!selectedDepositClient || !selectedDepositClient.lp_onboarded)"
@click="submitDeposit"></q-btn>
</q-card-actions>
</q-card>
@ -1195,9 +1203,10 @@
<q-card-section>
<p class="text-caption q-mb-md" :style="{opacity: 0.7}"
v-if="clientDialog.mode === 'add'">
LPs receive DCA distributions proportional to their remaining
balance. Each LP is scoped to a single machine; the same LP user
can register at multiple machines as separate rows.
Enrol an LP at one of your machines. Wallet, DCA mode, and
autoforward are configured by the LP themselves via the
<b>satmachineclient</b> extension — you can't set them here.
Deposits are refused until the LP has registered.
</p>
<q-select
@ -1217,38 +1226,9 @@
class="q-mb-md" dense outlined
:rules="[v => !!v || 'Required']"></q-input>
<q-input v-if="clientDialog.mode === 'add'"
v-model="clientDialog.data.wallet_id"
label="LP's wallet_id (receives DCA)"
hint="The LP's wallet where their sats land"
class="q-mb-md" dense outlined
:rules="[v => !!v || 'Required']"></q-input>
<q-input v-model="clientDialog.data.username"
label="Display name (optional)"
class="q-mb-md" dense outlined></q-input>
<q-select v-model="clientDialog.data.dca_mode"
:options="[{label: 'Flow (proportional)', value: 'flow'},
{label: 'Fixed (daily limit)', value: 'fixed'}]"
label="DCA mode"
emit-value map-options
class="q-mb-md" dense outlined></q-select>
<q-input v-if="clientDialog.data.dca_mode === 'fixed'"
v-model.number="clientDialog.data.fixed_mode_daily_limit"
label="Fixed mode daily limit (fiat)"
type="number" step="0.01"
class="q-mb-md" dense outlined></q-input>
<q-toggle v-model="clientDialog.data.autoforward_enabled"
label="Auto-forward DCA to external LN address"
class="q-mb-md"></q-toggle>
<q-input v-if="clientDialog.data.autoforward_enabled"
v-model="clientDialog.data.autoforward_ln_address"
label="LN address (e.g. user@walletofsatoshi.com)"
hint="LP-controlled; failures leave sats safely in LP's LNbits wallet"
hint="Operator-facing label only; doesn't affect distribution"
class="q-mb-md" dense outlined></q-input>
<q-select v-if="clientDialog.mode === 'edit'"