Export daily stats per user to CSV
This commit is contained in:
		
						commit
						e4634fedf2
					
				
							
								
								
									
										8
									
								
								.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								.env.example
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					DB_USER=lndhub
 | 
				
			||||||
 | 
					DB_PASSWORD=12345678
 | 
				
			||||||
 | 
					DB_NAME=lndhub
 | 
				
			||||||
 | 
					DB_HOST=10.1.1.1
 | 
				
			||||||
 | 
					# DB_PORT=5432
 | 
				
			||||||
 | 
					# BATCH_SIZE=10
 | 
				
			||||||
 | 
					# MAX_INVOICES=21000 # per user
 | 
				
			||||||
 | 
					# ANONYMIZATION_KEY=12345678
 | 
				
			||||||
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,4 @@
 | 
				
			|||||||
 | 
					.env
 | 
				
			||||||
 | 
					csv/
 | 
				
			||||||
 | 
					*.csv
 | 
				
			||||||
 | 
					sql/
 | 
				
			||||||
							
								
								
									
										210
									
								
								export-stats.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										210
									
								
								export-stats.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,210 @@
 | 
				
			|||||||
 | 
					// Import required modules
 | 
				
			||||||
 | 
					import { Client } from "jsr:@db/postgres";
 | 
				
			||||||
 | 
					import { load } from "jsr:@std/dotenv";
 | 
				
			||||||
 | 
					import { crypto } from "jsr:@std/crypto";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Load environment variables from .env file or system environment
 | 
				
			||||||
 | 
					const env = await load();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Validate required environment variables
 | 
				
			||||||
 | 
					for (const key of ["DB_USER", "DB_PASSWORD", "DB_NAME", "ANONYMIZATION_KEY"]) {
 | 
				
			||||||
 | 
					  if (!env[key]) {
 | 
				
			||||||
 | 
					    console.error(`Missing ${key} in .env or environment variables`);
 | 
				
			||||||
 | 
					    Deno.exit(1);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Adjust based on database capacity
 | 
				
			||||||
 | 
					const BATCH_SIZE = parseInt(env.BATCH_SIZE) || 10;
 | 
				
			||||||
 | 
					// Maximum number of invoices to process per user
 | 
				
			||||||
 | 
					// Skip user if exceeded (likely the case for streaming payments e.g.)
 | 
				
			||||||
 | 
					const MAX_INVOICES = parseInt(env.MAX_INVOICES) || 21000;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Database connection configuration from .env or environment variables
 | 
				
			||||||
 | 
					const dbConfig = {
 | 
				
			||||||
 | 
					  user: env.DB_USER,
 | 
				
			||||||
 | 
					  password: env.DB_PASSWORD,
 | 
				
			||||||
 | 
					  database: env.DB_NAME,
 | 
				
			||||||
 | 
					  hostname: env.DB_HOST || "localhost",
 | 
				
			||||||
 | 
					  port: parseInt(env.DB_PORT || "5432", 10),
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const client = new Client(dbConfig);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Connect to the database
 | 
				
			||||||
 | 
					try {
 | 
				
			||||||
 | 
					  await client.connect();
 | 
				
			||||||
 | 
					  console.log(`Connected to database`);
 | 
				
			||||||
 | 
					} catch {
 | 
				
			||||||
 | 
					  console.log(`Failed to connect to database`);
 | 
				
			||||||
 | 
					  Deno.exit(1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Fetch all user IDs
 | 
				
			||||||
 | 
					const userIdsResult = await client.queryObject("SELECT id FROM users");
 | 
				
			||||||
 | 
					const userIds = userIdsResult.rows.map((row) => row.id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// CSV file path
 | 
				
			||||||
 | 
					const csvFilePath = "./daily-stats.csv";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// CSV headers
 | 
				
			||||||
 | 
					const csvHeaders = [
 | 
				
			||||||
 | 
					  "User ID", // HMAC-SHA256 hash (truncated to 12 hex chars)
 | 
				
			||||||
 | 
					  "Date",
 | 
				
			||||||
 | 
					  "Balance Start of Day",
 | 
				
			||||||
 | 
					  "Balance End of Day",
 | 
				
			||||||
 | 
					  "Balance Max Day",
 | 
				
			||||||
 | 
					  "Balance Min Day",
 | 
				
			||||||
 | 
					  "Total Flow Out",
 | 
				
			||||||
 | 
					  "Total Flow In",
 | 
				
			||||||
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Write headers to CSV (create file or overwrite if it exists)
 | 
				
			||||||
 | 
					await Deno.writeTextFile(csvFilePath, csvHeaders.join(",") + "\n");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Function to compute HMAC-SHA256 hash of user_id, truncated to 48 bits (12 hex chars)
 | 
				
			||||||
 | 
					async function anonymizeUserId(userId) {
 | 
				
			||||||
 | 
					  const keyData = new TextEncoder().encode(env.ANONYMIZATION_KEY);
 | 
				
			||||||
 | 
					  const data = new TextEncoder().encode(userId.toString());
 | 
				
			||||||
 | 
					  const key = await crypto.subtle.importKey(
 | 
				
			||||||
 | 
					    "raw",
 | 
				
			||||||
 | 
					    keyData,
 | 
				
			||||||
 | 
					    { name: "HMAC", hash: "SHA-256" },
 | 
				
			||||||
 | 
					    false,
 | 
				
			||||||
 | 
					    ["sign"]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					  const signature = await crypto.subtle.sign("HMAC", key, data);
 | 
				
			||||||
 | 
					  const hashArray = Array.from(new Uint8Array(signature).slice(0, 6)); // Take first 6 bytes (48 bits)
 | 
				
			||||||
 | 
					  return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Function to process a single user and return CSV rows
 | 
				
			||||||
 | 
					async function processUser(userId, client) {
 | 
				
			||||||
 | 
					  // Check the number of settled invoices for the user
 | 
				
			||||||
 | 
					  const countResult = await client.queryObject(
 | 
				
			||||||
 | 
					    `SELECT COUNT(*) AS count
 | 
				
			||||||
 | 
					     FROM invoices
 | 
				
			||||||
 | 
					     WHERE user_id = $1 AND settled_at IS NOT NULL`,
 | 
				
			||||||
 | 
					    [userId]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					  const invoiceCount = Number(countResult.rows[0].count);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (invoiceCount > MAX_INVOICES) {
 | 
				
			||||||
 | 
					    const anonymizedUserId = await anonymizeUserId(userId);
 | 
				
			||||||
 | 
					    console.warn(`Skipping user ${anonymizedUserId}: ${invoiceCount} invoices exceed limit of ${MAX_INVOICES}`);
 | 
				
			||||||
 | 
					    return [];
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Fetch settled invoices for the user
 | 
				
			||||||
 | 
					  const invoicesResult = await client.queryObject(
 | 
				
			||||||
 | 
					    `SELECT settled_at, amount, type, service_fee, routing_fee
 | 
				
			||||||
 | 
					     FROM invoices
 | 
				
			||||||
 | 
					     WHERE user_id = $1 AND settled_at IS NOT NULL
 | 
				
			||||||
 | 
					     ORDER BY settled_at ASC`,
 | 
				
			||||||
 | 
					    [userId]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					  const invoices = invoicesResult.rows;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (invoices.length === 0) return [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Aggregate daily statistics using BigInt
 | 
				
			||||||
 | 
					  const dailyData = {};
 | 
				
			||||||
 | 
					  let runningBalance = BigInt(0); // Initialize as BigInt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  for (const invoice of invoices) {
 | 
				
			||||||
 | 
					    const day = invoice.settled_at.toISOString().split("T")[0]; // YYYY-MM-DD
 | 
				
			||||||
 | 
					    // Convert amounts to BigInt, handling NULL fees
 | 
				
			||||||
 | 
					    const amount = BigInt(invoice.amount);
 | 
				
			||||||
 | 
					    const serviceFee = invoice.service_fee ? BigInt(invoice.service_fee) : BigInt(0);
 | 
				
			||||||
 | 
					    const routingFee = invoice.routing_fee ? BigInt(invoice.routing_fee) : BigInt(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Calculate effective amount: include fees for outgoing, not for incoming
 | 
				
			||||||
 | 
					    const effectiveAmount = invoice.type === "incoming"
 | 
				
			||||||
 | 
					      ? amount
 | 
				
			||||||
 | 
					      : amount + serviceFee + routingFee; // Add fees for outgoing
 | 
				
			||||||
 | 
					    const signedAmount = invoice.type === "incoming" ? effectiveAmount : -effectiveAmount;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!dailyData[day]) {
 | 
				
			||||||
 | 
					      dailyData[day] = {
 | 
				
			||||||
 | 
					        balance_start_of_day: runningBalance,
 | 
				
			||||||
 | 
					        balance_end_of_day: runningBalance,
 | 
				
			||||||
 | 
					        balance_max_day: runningBalance,
 | 
				
			||||||
 | 
					        balance_min_day: runningBalance,
 | 
				
			||||||
 | 
					        total_flow_in: BigInt(0),
 | 
				
			||||||
 | 
					        total_flow_out: BigInt(0),
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Update running balance
 | 
				
			||||||
 | 
					    runningBalance += signedAmount;
 | 
				
			||||||
 | 
					    dailyData[day].balance_end_of_day = runningBalance;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Update min/max balance
 | 
				
			||||||
 | 
					    dailyData[day].balance_max_day = runningBalance > dailyData[day].balance_max_day
 | 
				
			||||||
 | 
					      ? runningBalance
 | 
				
			||||||
 | 
					      : dailyData[day].balance_max_day;
 | 
				
			||||||
 | 
					    dailyData[day].balance_min_day = runningBalance < dailyData[day].balance_min_day
 | 
				
			||||||
 | 
					      ? runningBalance
 | 
				
			||||||
 | 
					      : dailyData[day].balance_min_day;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Update flows
 | 
				
			||||||
 | 
					    if (signedAmount > 0) {
 | 
				
			||||||
 | 
					      dailyData[day].total_flow_in += signedAmount;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      dailyData[day].total_flow_out += -signedAmount; // Positive outflow value
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Generate CSV rows for this user with anonymized user_id
 | 
				
			||||||
 | 
					  const anonymizedUserId = await anonymizeUserId(userId);
 | 
				
			||||||
 | 
					  const rows = [];
 | 
				
			||||||
 | 
					  for (const [day, stats] of Object.entries(dailyData)) {
 | 
				
			||||||
 | 
					    rows.push([
 | 
				
			||||||
 | 
					      anonymizedUserId,
 | 
				
			||||||
 | 
					      day,
 | 
				
			||||||
 | 
					      stats.balance_start_of_day.toString(),
 | 
				
			||||||
 | 
					      stats.balance_end_of_day.toString(),
 | 
				
			||||||
 | 
					      stats.balance_max_day.toString(),
 | 
				
			||||||
 | 
					      stats.balance_min_day.toString(),
 | 
				
			||||||
 | 
					      stats.total_flow_out.toString(),
 | 
				
			||||||
 | 
					      stats.total_flow_in.toString(),
 | 
				
			||||||
 | 
					    ].join(","));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return rows;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Process users in parallel with batching
 | 
				
			||||||
 | 
					async function processUsersInParallel(userIds) {
 | 
				
			||||||
 | 
					  for (let i = 0; i < userIds.length; i += BATCH_SIZE) {
 | 
				
			||||||
 | 
					    const batch = userIds.slice(i, i + BATCH_SIZE);
 | 
				
			||||||
 | 
					    console.log(`Processing batch ${i / BATCH_SIZE + 1} of ${Math.ceil(userIds.length / BATCH_SIZE)}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Process users in parallel and collect rows
 | 
				
			||||||
 | 
					    const promises = batch.map(async (userId) => {
 | 
				
			||||||
 | 
					      const batchClient = new Client(dbConfig); // Use shared dbConfig
 | 
				
			||||||
 | 
					      await batchClient.connect();
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        return await processUser(userId, batchClient);
 | 
				
			||||||
 | 
					      } finally {
 | 
				
			||||||
 | 
					        await batchClient.end();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Wait for all users in the batch to complete
 | 
				
			||||||
 | 
					    const batchRows = (await Promise.all(promises)).flat();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Write all rows for the batch to CSV in one operation
 | 
				
			||||||
 | 
					    if (batchRows.length > 0) {
 | 
				
			||||||
 | 
					      await Deno.writeTextFile(csvFilePath, batchRows.join("\n") + "\n", { append: true });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Run parallel processing
 | 
				
			||||||
 | 
					await processUsersInParallel(userIds);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Close the main database connection
 | 
				
			||||||
 | 
					await client.end();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					console.log("Daily statistics written to", csvFilePath);
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user