From 7bafc67370b8eef09bba6e1060470b2643be0b5e Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 17 Jun 2025 19:26:31 +0200 Subject: [PATCH] Add DCA admin extension with CRUD operations for clients and deposits, including UI components for managing deposits and client details. --- static/js/index.js | 312 ++++++++++++++++++++++++++++-- templates/myextension/index.html | 317 ++++++++++++++++++++++++++++--- views_api.py | 169 +++++++++++++++- 3 files changed, 755 insertions(+), 43 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 58fd1b0..947aecb 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -4,6 +4,69 @@ window.app = Vue.createApp({ delimiters: ['${', '}'], data: function () { return { + // DCA Admin Data + dcaClients: [], + deposits: [], + totalDcaBalance: 0, + + // Table configurations + clientsTable: { + columns: [ + {name: 'user_id', align: 'left', label: 'User ID', field: 'user_id'}, + {name: 'wallet_id', align: 'left', label: 'Wallet ID', field: 'wallet_id'}, + {name: 'dca_mode', align: 'left', label: 'DCA Mode', field: 'dca_mode'}, + {name: 'fixed_mode_daily_limit', align: 'left', label: 'Daily Limit', field: 'fixed_mode_daily_limit'}, + {name: 'status', align: 'left', label: 'Status', field: 'status'} + ], + pagination: { + rowsPerPage: 10 + } + }, + depositsTable: { + columns: [ + {name: 'client_id', align: 'left', label: 'Client', field: 'client_id'}, + {name: 'amount', align: 'left', label: 'Amount', field: 'amount'}, + {name: 'currency', align: 'left', label: 'Currency', field: 'currency'}, + {name: 'status', align: 'left', label: 'Status', field: 'status'}, + {name: 'created_at', align: 'left', label: 'Created', field: 'created_at'}, + {name: 'notes', align: 'left', label: 'Notes', field: 'notes'} + ], + pagination: { + rowsPerPage: 10 + } + }, + + // Dialog states + clientFormDialog: { + show: false, + data: { + dca_mode: 'flow', + currency: 'GTQ' + } + }, + depositFormDialog: { + show: false, + data: { + currency: 'GTQ' + } + }, + clientDetailsDialog: { + show: false, + data: null, + balance: null + }, + + // Options + dcaModeOptions: [ + {label: 'Flow Mode', value: 'flow'}, + {label: 'Fixed Mode', value: 'fixed'} + ], + currencyOptions: [ + {label: 'GTQ', value: 'GTQ'}, + {label: 'USD', value: 'USD'} + ], + + // Legacy data (keep for backward compatibility) invoiceAmount: 10, qrValue: 'lnurlpay', myex: [], @@ -11,18 +74,8 @@ window.app = Vue.createApp({ columns: [ {name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'name', align: 'left', label: 'Name', field: 'name'}, - { - name: 'wallet', - align: 'left', - label: 'Wallet', - field: 'wallet' - }, - { - name: 'total', - align: 'left', - label: 'Total sent/received', - field: 'total' - } + {name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'}, + {name: 'total', align: 'left', label: 'Total sent/received', field: 'total'} ], pagination: { rowsPerPage: 10 @@ -45,6 +98,217 @@ window.app = Vue.createApp({ /////////////////////////////////////////////////// methods: { + // Utility Methods + formatCurrency(amount) { + if (!amount) return 'Q 0.00' + return `Q ${(amount / 100).toFixed(2)}` + }, + + formatDate(dateString) { + if (!dateString) return '' + return new Date(dateString).toLocaleDateString() + }, + + // DCA Client Methods + async getDcaClients() { + try { + const {data} = await LNbits.api.request( + 'GET', + '/myextension/api/v1/dca/clients', + this.g.user.wallets[0].inkey + ) + this.dcaClients = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + + async sendClientData() { + try { + const data = { + user_id: this.clientFormDialog.data.user_id, + wallet_id: this.clientFormDialog.data.wallet_id, + dca_mode: this.clientFormDialog.data.dca_mode, + fixed_mode_daily_limit: this.clientFormDialog.data.fixed_mode_daily_limit + } + + if (this.clientFormDialog.data.id) { + // Update existing client + const {data: updatedClient} = await LNbits.api.request( + 'PUT', + `/myextension/api/v1/dca/clients/${this.clientFormDialog.data.id}`, + this.g.user.wallets[0].adminkey, + data + ) + // Update client in array + const index = this.dcaClients.findIndex(c => c.id === updatedClient.id) + if (index !== -1) { + this.dcaClients.splice(index, 1, updatedClient) + } + } else { + // Create new client + const {data: newClient} = await LNbits.api.request( + 'POST', + '/myextension/api/v1/dca/clients', + this.g.user.wallets[0].adminkey, + data + ) + this.dcaClients.push(newClient) + } + + this.closeClientFormDialog() + this.$q.notify({ + type: 'positive', + message: this.clientFormDialog.data.id ? 'Client updated successfully' : 'Client created successfully', + timeout: 5000 + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + + closeClientFormDialog() { + this.clientFormDialog.show = false + this.clientFormDialog.data = { + dca_mode: 'flow', + currency: 'GTQ' + } + }, + + editClient(client) { + this.clientFormDialog.data = {...client} + this.clientFormDialog.show = true + }, + + async viewClientDetails(client) { + try { + const {data: balance} = await LNbits.api.request( + 'GET', + `/myextension/api/v1/dca/clients/${client.id}/balance`, + this.g.user.wallets[0].inkey + ) + this.clientDetailsDialog.data = client + this.clientDetailsDialog.balance = balance + this.clientDetailsDialog.show = true + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + + // Deposit Methods + async getDeposits() { + try { + const {data} = await LNbits.api.request( + 'GET', + '/myextension/api/v1/dca/deposits', + this.g.user.wallets[0].inkey + ) + this.deposits = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + + addDepositDialog(client) { + this.depositFormDialog.data = { + client_id: client.id, + client_name: `${client.user_id.substring(0, 8)}...`, + currency: 'GTQ' + } + this.depositFormDialog.show = true + }, + + async sendDepositData() { + try { + const data = { + client_id: this.depositFormDialog.data.client_id, + amount: this.depositFormDialog.data.amount, + currency: this.depositFormDialog.data.currency, + notes: this.depositFormDialog.data.notes + } + + if (this.depositFormDialog.data.id) { + // Update existing deposit (mainly for notes/status) + const {data: updatedDeposit} = await LNbits.api.request( + 'PUT', + `/myextension/api/v1/dca/deposits/${this.depositFormDialog.data.id}`, + this.g.user.wallets[0].adminkey, + {status: this.depositFormDialog.data.status, notes: data.notes} + ) + const index = this.deposits.findIndex(d => d.id === updatedDeposit.id) + if (index !== -1) { + this.deposits.splice(index, 1, updatedDeposit) + } + } else { + // Create new deposit + const {data: newDeposit} = await LNbits.api.request( + 'POST', + '/myextension/api/v1/dca/deposits', + this.g.user.wallets[0].adminkey, + data + ) + this.deposits.unshift(newDeposit) + } + + this.closeDepositFormDialog() + this.$q.notify({ + type: 'positive', + message: this.depositFormDialog.data.id ? 'Deposit updated successfully' : 'Deposit created successfully', + timeout: 5000 + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + + closeDepositFormDialog() { + this.depositFormDialog.show = false + this.depositFormDialog.data = { + currency: 'GTQ' + } + }, + + async confirmDeposit(deposit) { + try { + await LNbits.utils + .confirmDialog('Confirm that this deposit has been physically placed in the ATM machine?') + .onOk(async () => { + const {data: updatedDeposit} = await LNbits.api.request( + 'PUT', + `/myextension/api/v1/dca/deposits/${deposit.id}/status`, + this.g.user.wallets[0].adminkey, + {status: 'confirmed', notes: 'Confirmed by admin - money placed in machine'} + ) + const index = this.deposits.findIndex(d => d.id === deposit.id) + if (index !== -1) { + this.deposits.splice(index, 1, updatedDeposit) + } + this.$q.notify({ + type: 'positive', + message: 'Deposit confirmed! DCA is now active for this client.', + timeout: 5000 + }) + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + + editDeposit(deposit) { + this.depositFormDialog.data = {...deposit} + this.depositFormDialog.show = true + }, + + // Export Methods + async exportClientsCSV() { + await LNbits.utils.exportCSV(this.clientsTable.columns, this.dcaClients) + }, + + async exportDepositsCSV() { + await LNbits.utils.exportCSV(this.depositsTable.columns, this.deposits) + }, + + // Legacy Methods (keep for backward compatibility) async closeFormDialog() { this.formDialog.show = false this.formDialog.data = {} @@ -237,6 +501,30 @@ window.app = Vue.createApp({ //////LIFECYCLE FUNCTIONS RUNNING ON PAGE LOAD///// /////////////////////////////////////////////////// async created() { + // Load DCA admin data + await Promise.all([ + this.getDcaClients(), + this.getDeposits() + ]) + + // Calculate total DCA balance + this.calculateTotalDcaBalance() + + // Legacy data loading await this.getMyExtensions() + }, + + watch: { + deposits() { + this.calculateTotalDcaBalance() + } + }, + + computed: { + calculateTotalDcaBalance() { + this.totalDcaBalance = this.deposits + .filter(d => d.status === 'confirmed') + .reduce((total, deposit) => total + deposit.amount, 0) + } } }) diff --git a/templates/myextension/index.html b/templates/myextension/index.html index 038e712..6c87b22 100644 --- a/templates/myextension/index.html +++ b/templates/myextension/index.html @@ -1,18 +1,140 @@ - + {% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %} -
+
+ - New MyExtension +
+
DCA Client Management
+
+
+ + Add New Client + +
+
+ + + + + + +
+
+
Active DCA Clients
+
+
+ Export to CSV +
+
+ + + +
+
+ + + + +
+
+
Recent Deposits
+
+
+ Export to CSV +
+
+ + +
@@ -109,75 +231,99 @@
- {{SITE_TITLE}} MyExtension extension + {{SITE_TITLE}} DCA Admin Extension

- Simple extension you can use as a base for your own extension.
- Includes very simple LNURL-pay and LNURL-withdraw example. + Dollar Cost Averaging administration for Lamassu ATM integration.
+ Manage client deposits and DCA distribution settings.

- {% include "myextension/_api_docs.html" %} + + +
+
Active Clients:
+
${ dcaClients.filter(c => c.status === 'active').length }
+
+
+
Pending Deposits:
+
${ deposits.filter(d => d.status === 'pending').length }
+
+
+
Total DCA Balance:
+
${ formatCurrency(totalDcaBalance) }
+
+
+
- {% include "myextension/_myextension.html" %} + {% include "myextension/_api_docs.html" %}
- + - + - + + -
Update MyExtensionUpdate Client Create MyExtensionCreate Client Cancel + + + + + + + +
+ Deposit for: ${ depositFormDialog.data.client_name } +
+ + + +
+ Update Deposit + Create Deposit + Cancel +
+
+
+
+ + + + + + + +
Client Details
+
+ + + + User ID + ${ clientDetailsDialog.data.user_id } + + + + + Wallet ID + ${ clientDetailsDialog.data.wallet_id } + + + + + DCA Mode + ${ clientDetailsDialog.data.dca_mode } + + + + + Daily Limit + ${ formatCurrency(clientDetailsDialog.data.fixed_mode_daily_limit) } + + + + + Balance Summary + + Deposits: ${ formatCurrency(clientDetailsDialog.balance.total_deposits) } | + Payments: ${ formatCurrency(clientDetailsDialog.balance.total_payments) } | + Remaining: ${ formatCurrency(clientDetailsDialog.balance.remaining_balance) } + + + + +
+
+ Close +
+
+
+ diff --git a/views_api.py b/views_api.py index 1de4cd5..0f9ba7c 100644 --- a/views_api.py +++ b/views_api.py @@ -15,9 +15,26 @@ from .crud import ( get_myextension, get_myextensions, update_myextension, + # DCA CRUD operations + create_dca_client, + get_dca_clients, + get_dca_client, + update_dca_client, + delete_dca_client, + create_deposit, + get_all_deposits, + get_deposit, + update_deposit_status, + get_client_balance_summary, ) from .helpers import lnurler -from .models import CreateMyExtensionData, CreatePayment, MyExtension +from .models import ( + CreateMyExtensionData, CreatePayment, MyExtension, + # DCA models + CreateDcaClientData, DcaClient, UpdateDcaClientData, + CreateDepositData, DcaDeposit, UpdateDepositStatusData, + ClientBalanceSummary +) myextension_api_router = APIRouter() @@ -173,3 +190,153 @@ async def api_myextension_create_invoice(data: CreatePayment) -> dict: ) return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11} + + +################################################### +################ DCA API ENDPOINTS ################ +################################################### + +# DCA Client Endpoints + +@myextension_api_router.get("/api/v1/dca/clients") +async def api_get_dca_clients( + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> list[DcaClient]: + """Get all DCA clients""" + return await get_dca_clients() + + +@myextension_api_router.get("/api/v1/dca/clients/{client_id}") +async def api_get_dca_client( + client_id: str, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> DcaClient: + """Get a specific DCA client""" + client = await get_dca_client(client_id) + if not client: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="DCA client not found." + ) + return client + + +@myextension_api_router.post("/api/v1/dca/clients", status_code=HTTPStatus.CREATED) +async def api_create_dca_client( + data: CreateDcaClientData, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> DcaClient: + """Create a new DCA client""" + return await create_dca_client(data) + + +@myextension_api_router.put("/api/v1/dca/clients/{client_id}") +async def api_update_dca_client( + client_id: str, + data: UpdateDcaClientData, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> DcaClient: + """Update a DCA client""" + client = await get_dca_client(client_id) + if not client: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="DCA client not found." + ) + + updated_client = await update_dca_client(client_id, data) + if not updated_client: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to update client." + ) + return updated_client + + +@myextension_api_router.delete("/api/v1/dca/clients/{client_id}") +async def api_delete_dca_client( + client_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + """Delete a DCA client""" + client = await get_dca_client(client_id) + if not client: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="DCA client not found." + ) + + await delete_dca_client(client_id) + return {"message": "Client deleted successfully"} + + +@myextension_api_router.get("/api/v1/dca/clients/{client_id}/balance") +async def api_get_client_balance( + client_id: str, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> ClientBalanceSummary: + """Get client balance summary""" + client = await get_dca_client(client_id) + if not client: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="DCA client not found." + ) + + return await get_client_balance_summary(client_id) + + +# DCA Deposit Endpoints + +@myextension_api_router.get("/api/v1/dca/deposits") +async def api_get_deposits( + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> list[DcaDeposit]: + """Get all deposits""" + return await get_all_deposits() + + +@myextension_api_router.get("/api/v1/dca/deposits/{deposit_id}") +async def api_get_deposit( + deposit_id: str, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> DcaDeposit: + """Get a specific deposit""" + deposit = await get_deposit(deposit_id) + if not deposit: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Deposit not found." + ) + return deposit + + +@myextension_api_router.post("/api/v1/dca/deposits", status_code=HTTPStatus.CREATED) +async def api_create_deposit( + data: CreateDepositData, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> DcaDeposit: + """Create a new deposit""" + # Verify client exists + client = await get_dca_client(data.client_id) + if not client: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="DCA client not found." + ) + + return await create_deposit(data) + + +@myextension_api_router.put("/api/v1/dca/deposits/{deposit_id}/status") +async def api_update_deposit_status( + deposit_id: str, + data: UpdateDepositStatusData, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> DcaDeposit: + """Update deposit status (e.g., confirm deposit)""" + deposit = await get_deposit(deposit_id) + if not deposit: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Deposit not found." + ) + + updated_deposit = await update_deposit_status(deposit_id, data) + if not updated_deposit: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to update deposit." + ) + return updated_deposit