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 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]: async def get_dca_client(client_id: str) -> Optional[DcaClient]:
return await db.fetchone( 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}, {"id": client_id},
DcaClient, DcaClient,
) )
@ -213,9 +227,9 @@ async def get_dca_client_for_machine_user(
machine_id: str, user_id: str machine_id: str, user_id: str
) -> Optional[DcaClient]: ) -> Optional[DcaClient]:
return await db.fetchone( return await db.fetchone(
""" f"""
SELECT * FROM satoshimachine.dca_clients SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM}
WHERE machine_id = :machine_id AND user_id = :user_id WHERE c.machine_id = :machine_id AND c.user_id = :user_id
""", """,
{"machine_id": machine_id, "user_id": user_id}, {"machine_id": machine_id, "user_id": user_id},
DcaClient, 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]: async def get_dca_clients_for_machine(machine_id: str) -> List[DcaClient]:
return await db.fetchall( return await db.fetchall(
""" f"""
SELECT * FROM satoshimachine.dca_clients SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM}
WHERE machine_id = :machine_id WHERE c.machine_id = :machine_id
ORDER BY created_at DESC ORDER BY c.created_at DESC
""", """,
{"machine_id": machine_id}, {"machine_id": machine_id},
DcaClient, 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]: async def get_dca_clients_for_operator(operator_user_id: str) -> List[DcaClient]:
"""All clients across every machine this operator owns.""" """All clients across every machine this operator owns."""
return await db.fetchall( return await db.fetchall(
""" f"""
SELECT c.* SELECT {_CLIENT_SELECT}
FROM satoshimachine.dca_clients c FROM {_CLIENT_FROM}
JOIN satoshimachine.dca_machines m ON m.id = c.machine_id JOIN satoshimachine.dca_machines m ON m.id = c.machine_id
WHERE m.operator_user_id = :uid WHERE m.operator_user_id = :uid
ORDER BY c.created_at DESC 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]: async def get_dca_clients_for_user(user_id: str) -> List[DcaClient]:
"""LP cross-operator view — every machine this LP is registered at.""" """LP cross-operator view — every machine this LP is registered at."""
return await db.fetchall( return await db.fetchall(
""" f"""
SELECT * FROM satoshimachine.dca_clients SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM}
WHERE user_id = :user_id WHERE c.user_id = :user_id
ORDER BY created_at DESC ORDER BY c.created_at DESC
""", """,
{"user_id": user_id}, {"user_id": user_id},
DcaClient, DcaClient,

View file

@ -102,6 +102,10 @@ class DcaClient(BaseModel):
status: str status: str
created_at: datetime created_at: datetime
updated_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): class UpdateDcaClientData(BaseModel):

View file

@ -141,18 +141,21 @@ window.app = Vue.createApp({
}, },
clientsTable: { 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: [ columns: [
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'}, {name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
{name: 'username', label: 'LP', field: 'username', align: 'left'}, {name: 'username', label: 'LP', field: 'username', align: 'left'},
{name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'}, {name: 'onboarded', label: 'Onboarded', field: 'lp_onboarded', align: 'center'},
{name: 'dca_mode', label: 'Mode', field: 'dca_mode', align: 'left'},
{ {
name: 'remaining_balance', name: 'remaining_balance',
label: 'Balance', label: 'Balance',
field: 'remaining_balance', field: 'remaining_balance',
align: 'right' align: 'right'
}, },
{name: 'autoforward', label: '→', field: 'autoforward_enabled', align: 'center'},
{name: 'status', label: 'Status', field: 'status', align: 'left'}, {name: 'status', label: 'Status', field: 'status', align: 'left'},
{name: 'actions', label: '', field: 'id', align: 'right'} {name: 'actions', label: '', field: 'id', align: 'right'}
], ],
@ -257,11 +260,24 @@ window.app = Vue.createApp({
})) }))
}, },
depositClientOptions() { 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 => ({ return this.clients.map(c => ({
label: `${c.username || this.shortId(c.user_id)} @ ${this.machineNameById(c.machine_id)}`, label:
value: c.id `${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() { worklistBuckets() {
return [ return [
{ {
@ -559,9 +575,9 @@ window.app = Vue.createApp({
}) })
this._downloadCsv( this._downloadCsv(
'clients.csv', 'clients.csv',
['id', 'machine_id', 'machine_name', 'user_id', 'wallet_id', ['id', 'machine_id', 'machine_name', 'user_id',
'username', 'dca_mode', 'status', 'autoforward_enabled', 'username', 'lp_onboarded', 'status',
'autoforward_ln_address', 'total_deposits', 'total_payments', 'total_deposits', 'total_payments',
'remaining_balance', 'balance_currency', 'created_at'], 'remaining_balance', 'balance_currency', 'created_at'],
rows rows
) )
@ -881,12 +897,7 @@ window.app = Vue.createApp({
id: client.id, id: client.id,
machine_id: client.machine_id, machine_id: client.machine_id,
user_id: client.user_id, user_id: client.user_id,
wallet_id: client.wallet_id,
username: client.username || '', 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 status: client.status
} }
this.clientDialog.show = true this.clientDialog.show = true
@ -1277,15 +1288,13 @@ window.app = Vue.createApp({
}, },
_emptyClientForm() { _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 { return {
machine_id: null, machine_id: null,
user_id: '', user_id: '',
wallet_id: '',
username: '', username: '',
dca_mode: 'flow',
fixed_mode_daily_limit: null,
autoforward_enabled: false,
autoforward_ln_address: '',
status: 'active' status: 'active'
} }
}, },
@ -1294,30 +1303,13 @@ window.app = Vue.createApp({
return { return {
machine_id: d.machine_id, machine_id: d.machine_id,
user_id: (d.user_id || '').trim(), user_id: (d.user_id || '').trim(),
wallet_id: (d.wallet_id || '').trim(), username: (d.username || '').trim() || null
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
} }
}, },
_cleanClientUpdate(d) { _cleanClientUpdate(d) {
return { return {
username: (d.username || '').trim() || null, 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 status: d.status
} }
}, },

View file

@ -222,13 +222,21 @@
<q-td key="username"> <q-td key="username">
<span v-text="props.row.username || shortId(props.row.user_id)"></span> <span v-text="props.row.username || shortId(props.row.user_id)"></span>
</q-td> </q-td>
<q-td key="wallet_id"> <q-td key="onboarded">
<code :style="{fontSize: '0.8em'}" <q-icon v-if="props.row.lp_onboarded"
v-text="shortId(props.row.wallet_id)"></code> name="check_circle" color="green" size="sm">
</q-td> <q-tooltip>
<q-td key="dca_mode"> LP has onboarded via satmachineclient.
<q-badge :color="props.row.dca_mode === 'flow' ? 'blue' : 'purple'" Deposits and DCA distributions can proceed.
:label="props.row.dca_mode"></q-badge> </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>
<q-td key="remaining_balance" class="text-right"> <q-td key="remaining_balance" class="text-right">
<span v-if="clientBalances[props.row.id]" <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> v-text="formatFiat(clientBalances[props.row.id].remaining_balance, clientBalances[props.row.id].currency)"></span>
<span v-else :style="{opacity: 0.5}"></span> <span v-else :style="{opacity: 0.5}"></span>
</q-td> </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-td key="status">
<q-badge <q-badge
:color="props.row.status === 'active' ? 'green' : 'grey'" :color="props.row.status === 'active' ? 'green' : 'grey'"
@ -1128,6 +1124,17 @@
class="q-mb-md" dense outlined class="q-mb-md" dense outlined
:rules="[v => !!v || 'Pick an LP']"></q-select> :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" <q-input v-model.number="depositDialog.data.amount"
label="Amount (fiat)" label="Amount (fiat)"
type="number" step="0.01" min="0" type="number" step="0.01" min="0"
@ -1148,6 +1155,7 @@
<q-btn color="primary" <q-btn color="primary"
:label="depositDialog.mode === 'add' ? 'Record' : 'Save'" :label="depositDialog.mode === 'add' ? 'Record' : 'Save'"
:loading="depositDialog.saving" :loading="depositDialog.saving"
:disable="depositDialog.mode === 'add' && (!selectedDepositClient || !selectedDepositClient.lp_onboarded)"
@click="submitDeposit"></q-btn> @click="submitDeposit"></q-btn>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
@ -1195,9 +1203,10 @@
<q-card-section> <q-card-section>
<p class="text-caption q-mb-md" :style="{opacity: 0.7}" <p class="text-caption q-mb-md" :style="{opacity: 0.7}"
v-if="clientDialog.mode === 'add'"> v-if="clientDialog.mode === 'add'">
LPs receive DCA distributions proportional to their remaining Enrol an LP at one of your machines. Wallet, DCA mode, and
balance. Each LP is scoped to a single machine; the same LP user autoforward are configured by the LP themselves via the
can register at multiple machines as separate rows. <b>satmachineclient</b> extension — you can't set them here.
Deposits are refused until the LP has registered.
</p> </p>
<q-select <q-select
@ -1217,38 +1226,9 @@
class="q-mb-md" dense outlined class="q-mb-md" dense outlined
:rules="[v => !!v || 'Required']"></q-input> :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" <q-input v-model="clientDialog.data.username"
label="Display name (optional)" label="Display name (optional)"
class="q-mb-md" dense outlined></q-input> hint="Operator-facing label only; doesn't affect distribution"
<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"
class="q-mb-md" dense outlined></q-input> class="q-mb-md" dense outlined></q-input>
<q-select v-if="clientDialog.mode === 'edit'" <q-select v-if="clientDialog.mode === 'edit'"