feat(v2): Deposits tab — record/confirm/reject workflow (P9d)

Operator records fiat handed in by LPs and drives the pending → confirmed
status transition that promotes deposits into LP balances.

Template (Deposits tab content + dialogs):
  - Filter strip: status dropdown (all/pending/confirmed/rejected) and
    LP dropdown (filtered by all the operator's LPs across machines)
  - Table columns: status badge, LP (+ machine subtitle), amount, created
    at, confirmed at, notes, action menu
  - Action menu (only enabled on pending status — confirmed/rejected are
    immutable for audit):
      • Confirm — flips to status='confirmed' + refreshes LP balance
      • Reject  — opens reject dialog for optional reason notes
      • Edit    — amount/currency/notes change
      • Delete
  - Empty-state banners: orange if no LPs (deposits are LP-scoped), blue
    if LPs exist but no deposits yet, grey if filters return nothing
  - Record-deposit dialog: LP select (auto-derives machine), amount,
    currency, notes
  - Edit-deposit dialog: amount/currency/notes; LP+machine immutable
  - Reject-deposit dialog: optional reason text persisted with the status

JS:
  - loadDeposits, depositStatusColor, clientUsernameById helpers
  - depositClientOptions computed: includes machine name in each option
    label so operators see exactly where the deposit will land
  - filteredDeposits computed: client-side filter on the loaded list
    (no server-side filter param — operator's deposit volume small enough)
  - submitDeposit handles both create and update paths; the create body
    explicitly includes machine_id (auto-derived from the selected LP)
    so the server can cross-check (client_id, machine_id) alignment
  - confirmDepositStatus refreshes the LP balance after confirming,
    since the confirmed deposit now affects remaining_balance display

Routes wired:
  GET    /api/v1/dca/deposits
  POST   /api/v1/dca/deposits
  PUT    /api/v1/dca/deposits/{id}
  PUT    /api/v1/dca/deposits/{id}/status
  DELETE /api/v1/dca/deposits/{id}

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 18:05:25 +02:00
commit ce4d7e4dd6
2 changed files with 436 additions and 2 deletions

View file

@ -18,6 +18,13 @@ const MACHINES_PATH = `${API}/machines`
const SETTLEMENTS_PATH = `${API}/settlements`
const STUCK_PATH = `${API}/settlements/stuck`
const CLIENTS_PATH = `${API}/clients`
const DEPOSITS_PATH = `${API}/deposits`
const DEPOSIT_STATUS_COLOR = {
pending: 'orange',
confirmed: 'green',
rejected: 'red'
}
const SETTLEMENT_STATUS_COLOR = {
pending: 'grey',
@ -43,8 +50,14 @@ window.app = Vue.createApp({
machines: [],
clients: [],
clientBalances: {}, // {client_id: ClientBalanceSummary}
deposits: [],
worklistCount: 0,
depositsFilter: {
status: null,
client_id: null
},
// UI configuration -----------------------------------------------
machinesTable: {
columns: [
@ -64,6 +77,24 @@ window.app = Vue.createApp({
pagination: {rowsPerPage: 10, sortBy: 'name'}
},
depositsTable: {
columns: [
{name: 'status', label: 'Status', field: 'status', align: 'left'},
{name: 'client', label: 'LP / Machine', field: 'client_id', align: 'left'},
{name: 'amount', label: 'Amount', field: 'amount', align: 'right'},
{name: 'created_at', label: 'Created', field: 'created_at', align: 'left'},
{
name: 'confirmed_at',
label: 'Confirmed',
field: 'confirmed_at',
align: 'left'
},
{name: 'notes', label: 'Notes', field: 'notes', align: 'left'},
{name: 'actions', label: '', field: 'id', align: 'right'}
],
pagination: {rowsPerPage: 25, sortBy: 'created_at', descending: true}
},
clientsTable: {
columns: [
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
@ -140,6 +171,19 @@ window.app = Vue.createApp({
mode: 'add', // 'add' | 'edit'
data: this._emptyClientForm()
},
depositDialog: {
show: false,
saving: false,
mode: 'add',
data: this._emptyDepositForm()
},
rejectDepositDialog: {
show: false,
saving: false,
deposit: null,
notes: ''
},
settleBalanceDialog: {
show: false,
saving: false,
@ -167,6 +211,22 @@ window.app = Vue.createApp({
value: m.id
}))
},
depositClientOptions() {
return this.clients.map(c => ({
label: `${c.username || this.shortId(c.user_id)} @ ${this.machineNameById(c.machine_id)}`,
value: c.id
}))
},
filteredDeposits() {
let rows = this.deposits
if (this.depositsFilter.status) {
rows = rows.filter(d => d.status === this.depositsFilter.status)
}
if (this.depositsFilter.client_id) {
rows = rows.filter(d => d.client_id === this.depositsFilter.client_id)
}
return rows
},
machinesById() {
const map = {}
for (const m of this.machines) map[m.id] = m
@ -189,6 +249,7 @@ window.app = Vue.createApp({
this.loadSuperConfig(),
this.loadMachines(),
this.loadClients(),
this.loadDeposits(),
this.loadWorklistCount()
])
} finally {
@ -196,6 +257,26 @@ window.app = Vue.createApp({
}
},
async loadDeposits() {
try {
const {data} = await LNbits.api.request('GET', DEPOSITS_PATH)
this.deposits = data || []
} catch (e) {
this.deposits = []
this._notifyError(e, 'Failed to load deposits')
}
},
depositStatusColor(status) {
return DEPOSIT_STATUS_COLOR[status] || 'grey'
},
clientUsernameById(clientId) {
const c = this.clients.find(x => x.id === clientId)
if (!c) return this.shortId(clientId)
return c.username || this.shortId(c.user_id)
},
async loadClients() {
try {
const {data} = await LNbits.api.request('GET', CLIENTS_PATH)
@ -582,6 +663,147 @@ window.app = Vue.createApp({
})
},
// -----------------------------------------------------------------
// Deposits (P9d)
// -----------------------------------------------------------------
openAddDepositDialog() {
this.depositDialog.mode = 'add'
this.depositDialog.data = this._emptyDepositForm()
this.depositDialog.show = true
},
openEditDepositDialog(deposit) {
this.depositDialog.mode = 'edit'
this.depositDialog.data = {
id: deposit.id,
client_id: deposit.client_id,
amount: deposit.amount,
currency: deposit.currency,
notes: deposit.notes || ''
}
this.depositDialog.show = true
},
async submitDeposit() {
const d = this.depositDialog.data
this.depositDialog.saving = true
try {
if (this.depositDialog.mode === 'add') {
// machine_id is server-cross-checked but we send it explicitly.
const client = this.clients.find(c => c.id === d.client_id)
if (!client) throw new Error('client not found')
const body = {
client_id: d.client_id,
machine_id: client.machine_id,
amount: Number(d.amount),
currency: (d.currency || 'GTQ').trim(),
notes: (d.notes || '').trim() || null
}
const {data} = await LNbits.api.request('POST', DEPOSITS_PATH, null, body)
this.deposits.unshift(data)
Quasar.Notify.create({type: 'positive', message: 'Deposit recorded'})
} else {
const body = {
amount: Number(d.amount),
currency: (d.currency || 'GTQ').trim(),
notes: (d.notes || '').trim() || null
}
const {data} = await LNbits.api.request(
'PUT', `${DEPOSITS_PATH}/${d.id}`, null, body
)
this._replaceDeposit(data)
Quasar.Notify.create({type: 'positive', message: 'Deposit updated'})
}
this.depositDialog.show = false
} catch (e) {
this._notifyError(e, 'Save failed')
} finally {
this.depositDialog.saving = false
}
},
confirmDepositStatus(deposit, newStatus) {
const verb = newStatus === 'confirmed' ? 'Confirm' : 'Reject'
Quasar.Dialog.create({
title: `${verb} deposit?`,
message:
newStatus === 'confirmed'
? `Confirming will count this toward the LP's DCA balance.`
: `Rejecting marks it ignored; it won't affect balances.`,
cancel: true,
persistent: true
}).onOk(async () => {
try {
const {data} = await LNbits.api.request(
'PUT',
`${DEPOSITS_PATH}/${deposit.id}/status`,
null,
{status: newStatus, notes: deposit.notes || null}
)
this._replaceDeposit(data)
// Confirming changes the LP balance — refresh it.
if (newStatus === 'confirmed') {
await this._loadClientBalance(deposit.client_id)
}
Quasar.Notify.create({
type: 'positive',
message: `Deposit ${newStatus}`
})
} catch (e) {
this._notifyError(e, 'Status update failed')
}
})
},
openRejectDepositDialog(deposit) {
this.rejectDepositDialog.deposit = deposit
this.rejectDepositDialog.notes = ''
this.rejectDepositDialog.show = true
},
async submitRejectDeposit() {
const d = this.rejectDepositDialog
d.saving = true
try {
const {data} = await LNbits.api.request(
'PUT',
`${DEPOSITS_PATH}/${d.deposit.id}/status`,
null,
{status: 'rejected', notes: d.notes || null}
)
this._replaceDeposit(data)
d.show = false
Quasar.Notify.create({type: 'positive', message: 'Deposit rejected'})
} catch (e) {
this._notifyError(e, 'Reject failed')
} finally {
d.saving = false
}
},
confirmDeleteDeposit(deposit) {
Quasar.Dialog.create({
title: 'Delete deposit?',
message: 'Only pending deposits can be deleted.',
cancel: true,
persistent: true
}).onOk(async () => {
try {
await LNbits.api.request('DELETE', `${DEPOSITS_PATH}/${deposit.id}`)
this.deposits = this.deposits.filter(x => x.id !== deposit.id)
Quasar.Notify.create({type: 'positive', message: 'Deposit deleted'})
} catch (e) {
this._notifyError(e, 'Delete failed')
}
})
},
_replaceDeposit(updated) {
if (!updated) return
const idx = this.deposits.findIndex(d => d.id === updated.id)
if (idx >= 0) this.deposits[idx] = updated
},
// -----------------------------------------------------------------
// Settle balance (P3e — closes #4)
// -----------------------------------------------------------------
@ -673,6 +895,15 @@ window.app = Vue.createApp({
})
},
_emptyDepositForm() {
return {
client_id: null,
amount: null,
currency: 'GTQ',
notes: ''
}
},
_emptyClientForm() {
return {
machine_id: null,