/ src / cli / purchases.js
purchases.js
  1  #!/usr/bin/env node
  2  
  3  /**
  4   * Purchases CLI
  5   *
  6   * Manage Audit&Fix report purchases.
  7   *
  8   * Usage:
  9   *   node src/cli/purchases.js list [status]
 10   *   node src/cli/purchases.js refund <id> [reason]
 11   *   node src/cli/purchases.js retry <id>
 12   *   node src/cli/purchases.js stats
 13   */
 14  
 15  import '../utils/load-env.js';
 16  import { run, getOne, getAll } from '../utils/db.js';
 17  import Logger from '../utils/logger.js';
 18  
 19  const logger = new Logger('PurchasesCLI');
 20  
 21  async function listPurchases(status) {
 22    let query =
 23      'SELECT id, email, landing_page_url, amount, currency, status, report_grade, created_at, delivered_at FROM purchases';
 24    const params = [];
 25  
 26    if (status) {
 27      query += ' WHERE status = $1';
 28      params.push(status);
 29    }
 30  
 31    query += ' ORDER BY created_at DESC LIMIT 50';
 32  
 33    const purchases = await getAll(query, params.length ? params : undefined);
 34  
 35    if (purchases.length === 0) {
 36      console.log('No purchases found.');
 37      return;
 38    }
 39  
 40    console.table(
 41      purchases.map(p => ({
 42        ID: p.id,
 43        Email: p.email,
 44        URL: p.landing_page_url.substring(0, 40),
 45        Amount: `${p.currency} ${(p.amount / 100).toFixed(2)}`,
 46        Status: p.status,
 47        Grade: p.report_grade || '-',
 48        Created: p.created_at?.toISOString?.()?.substring(0, 16) ?? String(p.created_at ?? '').substring(0, 16),
 49        Delivered: p.delivered_at
 50          ? (p.delivered_at?.toISOString?.()?.substring(0, 16) ?? String(p.delivered_at).substring(0, 16))
 51          : '-',
 52      }))
 53    );
 54  }
 55  
 56  async function refundPurchase(id, reason) {
 57    const purchase = await getOne('SELECT * FROM purchases WHERE id = $1', [id]);
 58    if (!purchase) {
 59      logger.error(`Purchase #${id} not found`);
 60      return;
 61    }
 62  
 63    if (purchase.status === 'refunded') {
 64      logger.warn(`Purchase #${id} is already refunded`);
 65      return;
 66    }
 67  
 68    if (!purchase.paypal_capture_id) {
 69      logger.error(`Purchase #${id} has no PayPal capture ID`);
 70      return;
 71    }
 72  
 73    // Import and call PayPal refund
 74    const { refundPayment } = await import('../payment/paypal.js');
 75    const result = await refundPayment(purchase.paypal_capture_id, reason);
 76  
 77    // Update purchase status
 78    await run(
 79      `UPDATE purchases
 80       SET status = 'refunded', refunded_at = NOW(), refund_reason = $1, updated_at = NOW()
 81       WHERE id = $2`,
 82      [reason || 'Refunded via CLI', id]
 83    );
 84  
 85    logger.success(`Purchase #${id} refunded: ${result.refund_id}`);
 86  }
 87  
 88  async function retryPurchase(id) {
 89    const purchase = await getOne('SELECT * FROM purchases WHERE id = $1', [id]);
 90    if (!purchase) {
 91      logger.error(`Purchase #${id} not found`);
 92      return;
 93    }
 94  
 95    if (!['failed', 'processing'].includes(purchase.status)) {
 96      logger.warn(
 97        `Purchase #${id} status is '${purchase.status}', expected 'failed' or 'processing'`
 98      );
 99      return;
100    }
101  
102    await run(
103      `UPDATE purchases
104       SET status = 'paid', error_message = NULL, retry_count = 0, updated_at = NOW()
105       WHERE id = $1`,
106      [id]
107    );
108  
109    logger.success(`Purchase #${id} re-queued for processing`);
110  }
111  
112  async function showStats() {
113    // Check if table exists
114    const tableExists = await getOne(
115      "SELECT tablename FROM pg_tables WHERE schemaname = current_schema() AND tablename = 'purchases'"
116    );
117  
118    if (!tableExists) {
119      console.log('Purchases table does not exist yet. Run the migration first.');
120      return;
121    }
122  
123    const statusCounts = await getAll(
124      'SELECT status, COUNT(*) as count FROM purchases GROUP BY status ORDER BY count DESC'
125    );
126  
127    const totalRevenue = await getOne(
128      `SELECT
129         COUNT(*) as total_orders,
130         COALESCE(SUM(amount_usd), 0) as total_usd,
131         COALESCE(AVG(amount_usd), 0) as avg_usd
132       FROM purchases WHERE status NOT IN ('refunded', 'failed')`
133    );
134  
135    const revenueByCountry = await getAll(
136      `SELECT country_code, COUNT(*) as orders, SUM(amount_usd) as revenue_usd
137       FROM purchases WHERE status NOT IN ('refunded', 'failed')
138       GROUP BY country_code ORDER BY revenue_usd DESC LIMIT 10`
139    );
140  
141    const avgDelivery = await getOne(
142      `SELECT AVG(EXTRACT(EPOCH FROM (delivered_at - created_at)) / 3600) as avg_hours
143       FROM purchases WHERE delivered_at IS NOT NULL`
144    );
145  
146    console.log('\n=== Purchase Statistics ===\n');
147  
148    console.log('By Status:');
149    statusCounts.forEach(s => console.log(`  ${s.status}: ${s.count}`));
150  
151    console.log(
152      `\nRevenue: $${(Number(totalRevenue.total_usd) / 100).toFixed(2)} USD (${totalRevenue.total_orders} orders)`
153    );
154    console.log(`Average: $${(Number(totalRevenue.avg_usd) / 100).toFixed(2)} USD`);
155  
156    if (avgDelivery?.avg_hours) {
157      console.log(`Avg Delivery: ${Number(avgDelivery.avg_hours).toFixed(1)} hours`);
158    }
159  
160    if (revenueByCountry.length > 0) {
161      console.log('\nTop Countries:');
162      revenueByCountry.forEach(c =>
163        console.log(
164          `  ${c.country_code || 'Unknown'}: ${c.orders} orders, $${(Number(c.revenue_usd) / 100).toFixed(2)}`
165        )
166      );
167    }
168  
169    console.log('');
170  }
171  
172  async function main() {
173    const command = process.argv[2];
174  
175    try {
176      switch (command) {
177        case 'list':
178          await listPurchases(process.argv[3]);
179          break;
180        case 'refund': {
181          const id = parseInt(process.argv[3]);
182          const reason = process.argv.slice(4).join(' ') || undefined;
183          if (!id) {
184            console.error('Usage: node src/cli/purchases.js refund <id> [reason]');
185            process.exit(1);
186          }
187          await refundPurchase(id, reason);
188          break;
189        }
190        case 'retry': {
191          const retryId = parseInt(process.argv[3]);
192          if (!retryId) {
193            console.error('Usage: node src/cli/purchases.js retry <id>');
194            process.exit(1);
195          }
196          await retryPurchase(retryId);
197          break;
198        }
199        case 'stats':
200          await showStats();
201          break;
202        default:
203          console.log('Purchases CLI');
204          console.log('');
205          console.log('Usage:');
206          console.log('  list [status]     Show purchases (optional: filter by status)');
207          console.log('  refund <id> [reason]  Trigger PayPal refund');
208          console.log('  retry <id>        Re-queue failed purchase');
209          console.log('  stats             Revenue statistics');
210          console.log('');
211          console.log('Statuses: paid, processing, report_generated, delivered, failed, refunded');
212          process.exit(command ? 1 : 0);
213      }
214    } catch (error) {
215      logger.error(error.message);
216      process.exit(1);
217    }
218  }
219  
220  main();