import { BaseCommand, args, flags } from '@adonisjs/core/ace' import type { CommandOptions } from '@adonisjs/core/types/ace' import { DateTime } from 'luxon' import Invoice from '#models/invoice' import BankAccount from '#models/bank_account' import BankConnection from '#models/bank_connection' import BankTransaction from '#models/bank_transaction' import { reconcileTransactionsForOrg } from '#services/banking/reconcile_transactions' /** * Simule un virement bancaire entrant qui matche une facture donnée. * * Pourquoi : le sandbox Powens ne permet pas de "faire" de vrais virements * (les comptes sont des fixtures avec des transactions pré-générées qui * ne matchent pas les factures qu'on crée dans Rubis). Cette commande * injecte directement une bank_transaction synthétique avec montant + label * forgés pour matcher la facture, puis relance le reconcile. * * Réservé dev/test — ne pas exposer en prod. * * Usage : * node ace banking:simulate-payment * node ace banking:simulate-payment --no-reconcile # juste insérer */ export default class BankingSimulatePayment extends BaseCommand { static commandName = 'banking:simulate-payment' static description = 'Injecte une transaction synthétique qui matche une facture (dev)' static options: CommandOptions = { startApp: true, } @args.string({ description: 'Facture id (UUID) à simuler payée' }) declare invoiceId: string @flags.boolean({ description: 'Lance reconcile après injection (default true)', default: true, }) declare reconcile: boolean async run() { const invoice = await Invoice.query() .where('id', this.invoiceId) .preload('client') .first() if (!invoice) { this.logger.error(`Facture ${this.invoiceId} introuvable`) return } this.logger.info( `Facture trouvée : ${invoice.numero} · ${invoice.client.name} · ${(invoice.amountTtcCents / 100).toFixed(2)} €` ) // Récupère le 1er compte courant de la 1re connection active de l'org. const connection = await BankConnection.query() .where('organizationId', invoice.organizationId) .where('state', 'active') .orderBy('createdAt', 'desc') .first() if (!connection) { this.logger.error('Aucune connection bancaire active sur cette org. Connecte une banque d\'abord.') return } const account = await BankAccount.query() .where('bankConnectionId', connection.id) .where('type', 'checking') .first() if (!account) { this.logger.error('Aucun compte de type "checking" sur la connexion. Aucun compte à matcher.') return } // Label "réaliste" : style VIR SEPA + nom client + numéro de facture // pour que le matching HIGH-confidence trigger (label contient numero // ou nom client). const label = `VIR SEPA ${invoice.client.name.toUpperCase()} REF ${invoice.numero}` const tx = await BankTransaction.create({ bankAccountId: account.id, powensId: BigInt(Date.now()) as any, // pseudo-id unique amountCents: invoice.amountTtcCents, // crédit, montant exact label, wording: 'Virement reçu', valueDate: DateTime.now(), bookedAt: DateTime.now(), raw: { synthetic: true, sourceCommand: 'banking:simulate-payment', invoiceId: invoice.id, }, matchStatus: 'unmatched', }) this.logger.success(`Transaction synthétique créée : ${tx.id}`) this.logger.info(` Compte : ${account.name}`) this.logger.info(` Montant : ${(tx.amountCents / 100).toFixed(2)} €`) this.logger.info(` Label : ${tx.label}`) if (this.reconcile) { this.logger.info('---') this.logger.info('Lancement du reconcile…') const result = await reconcileTransactionsForOrg(invoice.organizationId) this.logger.success( `Scanné : ${result.scanned} · Auto-confirmé : ${result.autoConfirmed} · Suggéré : ${result.suggested}` ) } else { this.logger.info('Reconcile sauté (--no-reconcile). Lance `node ace banking:reconcile` quand prêt.') } } }