rubis/apps/api/database/schema.ts
ordinarthur 51217175ad
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 38s
Build & Deploy API / build-and-deploy (push) Successful in 1m36s
feat(banking): intégration Powens AISP + auto-réconciliation factures
Module banking complet en lecture seule via Powens (ex-Budget Insight)
pour détecter automatiquement les paiements clients et arrêter les
relances dès qu'une facture est payée. Réservé plans Pro / Business,
kill switch global BANKING_ENABLED désactivé en prod tant que le KYC
Powens n'est pas validé (cf. .claude/deploy-memory.md).

Backend (apps/api)
- PowensClient bas niveau : init user, code temporaire 30s, build
  webview URL, list/get/delete connections, accounts, transactions,
  vérif HMAC SHA-256 timing-safe pour webhook.
- BankingService : ensurePowensUser (chiffrement token via Adonis
  encryption / APP_KEY), createWebviewUrl avec state HMAC anti-CSRF
  (TTL 10 min), handleCallback (upsert connection + accounts +
  fire-and-forget mail + sync 90j + reconcile), disconnect (DELETE
  Powens + soft-revoke en DB), setReconciliationMode.
- Réconciliation : match transactions ↔ factures sur montant exact
  + label normalisé (numero ou nom client, NFD strip + alphanum).
  Confiance HIGH (label matche) vs LOW (montant seul). Mode auto +
  HIGH → invoice.status=paid + bonus rubis + cancel relances +
  enqueuePaymentThanks (client) + sendInvoiceAutoPaidNotification
  (user). Mode manual ou LOW → match_status='suggested' (UI V2).
- Webhook /webhooks/powens : vérif HMAC, lookup org par
  powens_user_id, dispatch CONNECTION_SYNCED / NEW_TRANSACTIONS /
  USER_SYNC_ENDED → sync incrémental 7j + reconcile, CONNECTION_ERROR
  / SCA_REQUIRED → update state + last_error. Réponse 200 immédiate
  puis processing fire-and-forget pour ne pas timeout côté Powens.
- 4 migrations : bank_connections, bank_accounts, bank_transactions
  + colonnes powens_user_id (chiffré APP_KEY) et reconciliation_mode
  sur organizations.
- 2 templates React Email : BankConnectedEmail (post-connection,
  récap comptes + lien settings) et InvoiceAutoPaidNotificationEmail
  (notif user après match auto, lien direct facture + libellé
  bancaire détecté). Toujours en branding Rubis (notif Rubis → user,
  jamais marque blanche).
- 2 commandes ace : banking:reconcile (rejoue le reconcile sans
  reconnecter la banque) et banking:simulate-payment (injecte une
  bank_transaction synthétique qui matche une facture, pour test E2E
  sans devoir attendre un vrai virement sandbox).
- Kill switch isBankingEnabled() : flag BANKING_ENABLED + check des
  credentials Powens. Endpoint public GET /banking/status renvoie
  { enabled }, /banking/powens/init throw 503 banking_disabled si OFF.
- Fix handler exceptions : UNIQUE violation composite (org, X)
  rapporte désormais la vraie colonne en faute (numero/slug/…) avec
  message lisible « Le numéro de facture "F2026-0013" existe déjà »,
  au lieu d'un message ambigu sur organization_id.

Frontend (apps/web)
- /parametres : nouvelle SettingsSection "Banque" gated par kill
  switch + plan Pro/Business. Si Free → upsell card avec CTA vers
  /parametres/abonnement. Si Pro/Business sans banque → CTA "Connecter
  une banque". Si banque connectée → carte avec accounts (IBAN
  masqué FR76 **** **** **** 1234), solde, last sync, bouton
  Déconnecter. Toggle Manuel/Auto pour reconciliation_mode.
- /parametres/banque/success : nouvelle route dédiée post-callback
  avec badge ✓ animé + halo glow rubis, récap des comptes
  synchronisés, 2 CTAs ("Voir mes paramètres" / "Retour dashboard"),
  note sécurité "lecture seule, aucun déplacement de fonds".
- Hooks : useBankingStatus, useBankConnections (avec opt-out via
  { enabled }), useInitBanking, useDisconnectBank, useBankingSettings,
  useUpdateBankingSettings.

Infrastructure (k3s)
- ConfigMap rubis-api-config : BANKING_ENABLED='false' par défaut,
  BANKING_PROVIDER='powens', POWENS_DOMAIN='rubis',
  POWENS_API_BASE_URL='https://rubis.biapi.pro/2.0/',
  POWENS_REDIRECT_URI='https://app.rubis.pro/api/v1/banking/powens/callback'.
- Secret rubis-app-secrets : 3 nouvelles clés POWENS_CLIENT_ID,
  POWENS_CLIENT_SECRET, POWENS_WEBHOOK_SECRET (valeurs sandbox posées
  via kubectl patch, à remplacer post-KYC).

Sécurité
- Token Powens chiffré au repos via Adonis encryption (AES-256-GCM,
  clé APP_KEY).
- State HMAC SHA-256 signé sur APP_KEY pour le flow webview
  (anti-CSRF + porte l'org_id à travers le redirect).
- Webhook HMAC SHA-256 sur header BI-Signature avec
  POWENS_WEBHOOK_SECRET, comparaison timing-safe.
- IBAN masqué côté API (transformer).
- Scope par org sur tous les endpoints (anti-IDOR).
- Rate limiting via le middleware Adonis existant.
- Idempotence DB : UNIQUE (org, powens_connection_id), (connection,
  powens_account_id), (account, powens_id) → rejouer un event ou un
  callback ne pose pas de problème.

Documentation
- /docs/tech/banking-setup.md : procédure complète setup dev avec
  Cloudflare Quick Tunnel, compte sandbox Powens, whitelist URLs.
- /.claude/deploy-memory.md : section "Banking (Powens) — activation
  prod" avec procédure en 6 étapes (KYC → secrets → ConfigMap →
  flip flag → smoke test), snippet kubectl patch pour rotation
  ciblée de secrets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:03:32 +02:00

523 lines
17 KiB
TypeScript

/**
* This file is automatically generated
* DO NOT EDIT manually
* Run "node ace migration:run" command to re-generate this file
*/
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'
export class ActivityEventSchema extends BaseModel {
static $columns = ['at', 'createdAt', 'id', 'kind', 'label', 'meta', 'organizationId', 'updatedAt'] as const
$columns = ActivityEventSchema.$columns
@column.dateTime()
declare at: DateTime
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: string
@column()
declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
@column()
declare label: string
@column()
declare meta: { invoiceId?: string; clientId?: string; planStepOrder?: number; [k: string]: unknown }
@column()
declare organizationId: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class AuthAccessTokenSchema extends BaseModel {
static $columns = ['abilities', 'createdAt', 'expiresAt', 'hash', 'id', 'lastUsedAt', 'name', 'tokenableId', 'type', 'updatedAt'] as const
$columns = AuthAccessTokenSchema.$columns
@column()
declare abilities: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime | null
@column.dateTime()
declare expiresAt: DateTime | null
@column()
declare hash: string
@column({ isPrimary: true })
declare id: string
@column.dateTime()
declare lastUsedAt: DateTime | null
@column()
declare name: string | null
@column()
declare tokenableId: string
@column()
declare type: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class BankAccountSchema extends BaseModel {
static $columns = ['balanceCents', 'bankConnectionId', 'createdAt', 'currency', 'iban', 'id', 'name', 'powensAccountId', 'type', 'updatedAt'] as const
$columns = BankAccountSchema.$columns
@column()
declare balanceCents: number | null
@column()
declare bankConnectionId: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare currency: string
@column()
declare iban: string | null
@column({ isPrimary: true })
declare id: string
@column()
declare name: string
@column()
declare powensAccountId: bigint | number
@column()
declare type: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class BankConnectionSchema extends BaseModel {
static $columns = ['bankLogoUrl', 'bankName', 'createdAt', 'id', 'lastError', 'lastSyncAt', 'organizationId', 'powensConnectionId', 'state', 'updatedAt'] as const
$columns = BankConnectionSchema.$columns
@column()
declare bankLogoUrl: string | null
@column()
declare bankName: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: string
@column()
declare lastError: string | null
@column.dateTime()
declare lastSyncAt: DateTime | null
@column()
declare organizationId: string
@column()
declare powensConnectionId: bigint | number
@column()
declare state: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class BankTransactionSchema extends BaseModel {
static $columns = ['amountCents', 'bankAccountId', 'bookedAt', 'createdAt', 'id', 'label', 'matchStatus', 'matchedInvoiceId', 'powensId', 'raw', 'valueDate', 'wording'] as const
$columns = BankTransactionSchema.$columns
@column()
declare amountCents: number
@column()
declare bankAccountId: string
@column.dateTime()
declare bookedAt: DateTime | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: string
@column()
declare label: string
@column()
declare matchStatus: string
@column()
declare matchedInvoiceId: string | null
@column()
declare powensId: bigint | number
@column()
declare raw: any
@column.date()
declare valueDate: DateTime
@column()
declare wording: string | null
}
export class CheckinTaskSchema extends BaseModel {
static $columns = ['answer', 'answeredAt', 'createdAt', 'id', 'invoiceId', 'organizationId', 'sendAt', 'sentAt', 'status', 'tokenHash', 'updatedAt'] as const
$columns = CheckinTaskSchema.$columns
@column()
declare answer: 'paid' | 'still_pending' | null | null
@column.dateTime()
declare answeredAt: DateTime | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: string
@column()
declare invoiceId: string
@column()
declare organizationId: string
@column.dateTime()
declare sendAt: DateTime
@column.dateTime()
declare sentAt: DateTime | null
@column()
declare status: 'scheduled' | 'sent' | 'answered' | 'expired'
@column()
declare tokenHash: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class ClientSchema extends BaseModel {
static $columns = ['address', 'contactFirstName', 'contactLastName', 'createdAt', 'email', 'id', 'name', 'notes', 'organizationId', 'phone', 'siret', 'updatedAt'] as const
$columns = ClientSchema.$columns
@column()
declare address: string | null
@column()
declare contactFirstName: string | null
@column()
declare contactLastName: string | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare email: string
@column({ isPrimary: true })
declare id: string
@column()
declare name: string
@column()
declare notes: string | null
@column()
declare organizationId: string
@column()
declare phone: string | null
@column()
declare siret: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class DemoCapturedEmailSchema extends BaseModel {
static $columns = ['body', 'createdAt', 'fromEmail', 'fromName', 'id', 'kind', 'meta', 'organizationId', 'replyTo', 'sentAt', 'subject', 'toEmail', 'toName', 'updatedAt'] as const
$columns = DemoCapturedEmailSchema.$columns
@column()
declare body: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare fromEmail: string
@column()
declare fromName: string | null
@column({ isPrimary: true })
declare id: string
@column()
declare kind: string
@column()
declare meta: any
@column()
declare organizationId: string
@column()
declare replyTo: string | null
@column.dateTime()
declare sentAt: DateTime
@column()
declare subject: string
@column()
declare toEmail: string
@column()
declare toName: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class ImportBatchSchema extends BaseModel {
static $columns = ['createdAt', 'id', 'organizationId', 'updatedAt'] as const
$columns = ImportBatchSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: string
@column()
declare organizationId: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class ImportDraftSchema extends BaseModel {
static $columns = ['batchId', 'confidence', 'createdAt', 'edited', 'extracted', 'filename', 'id', 'invoiceId', 'pdfStorageKey', 'status', 'updatedAt'] as const
$columns = ImportDraftSchema.$columns
@column()
declare batchId: string
@column()
declare confidence: Partial<{ clientId: number; clientName: number; clientEmail: number; numero: number; amountTtcCents: number; issueDate: number; dueDate: number; planId: number }>
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare edited: { clientId: string | null; clientName: string; clientEmail: string | null; numero: string; amountTtcCents: number; issueDate: string; dueDate: string; planId: string | null }
@column()
declare extracted: { clientId: string | null; clientName: string; clientEmail: string | null; numero: string; amountTtcCents: number; issueDate: string; dueDate: string; planId: string | null }
@column()
declare filename: string
@column({ isPrimary: true })
declare id: string
@column()
declare invoiceId: string | null
@column()
declare pdfStorageKey: string | null
@column()
declare status: 'pending' | 'validated' | 'skipped'
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class InvoiceSchema extends BaseModel {
static $columns = ['amountTtcCents', 'clientId', 'createdAt', 'dueDate', 'id', 'issueDate', 'notes', 'numero', 'organizationId', 'paidAt', 'pdfStorageKey', 'planId', 'rubisEarned', 'status', 'updatedAt'] as const
$columns = InvoiceSchema.$columns
@column()
declare amountTtcCents: number
@column()
declare clientId: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime()
declare dueDate: DateTime
@column({ isPrimary: true })
declare id: string
@column.dateTime()
declare issueDate: DateTime
@column()
declare notes: string | null
@column()
declare numero: string
@column()
declare organizationId: string
@column.dateTime()
declare paidAt: DateTime | null
@column()
declare pdfStorageKey: string | null
@column()
declare planId: string | null
@column()
declare rubisEarned: number
@column()
declare status: 'pending' | 'awaiting_user_confirmation' | 'in_relance' | 'paid' | 'litigation' | 'cancelled'
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class OrganizationSchema extends BaseModel {
static $columns = ['billingCycle', 'brandSettings', 'cancelAtPeriodEnd', 'createdAt', 'currentPeriodEnd', 'demoMode', 'demoSpeedFactor', 'gracePeriodEndsAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'plan', 'powensTokenEncrypted', 'powensUserId', 'reconciliationMode', 'rubisCount', 'siret', 'stripeCustomerId', 'stripeSubscriptionId', 'subscriptionStatus', 'updatedAt', 'virtualNow'] as const
$columns = OrganizationSchema.$columns
@column()
declare billingCycle: string | null
@column()
declare brandSettings: { logoPath?: string | null; logoUrl?: string | null; senderName?: string | null; primaryColor?: string | null; bannerColor?: string | null; bodyBgColor?: string | null; cardBgColor?: string | null; textColor?: string | null; textMutedColor?: string | null; borderColor?: string | null; linkColor?: string | null; buttonTextColor?: string | null } | null | null
@column()
declare cancelAtPeriodEnd: boolean
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime()
declare currentPeriodEnd: DateTime | null
@column()
declare demoMode: boolean
@column()
declare demoSpeedFactor: number
@column.dateTime()
declare gracePeriodEndsAt: DateTime | null
@column({ isPrimary: true })
declare id: string
@column()
declare monthlyVolumeBucket: string | null
@column()
declare name: string
@column.dateTime()
declare onboardingCompletedAt: DateTime | null
@column()
declare plan: string
@column()
declare powensTokenEncrypted: string | null
@column()
declare powensUserId: bigint | number | null
@column()
declare reconciliationMode: string
@column()
declare rubisCount: number
@column()
declare siret: string | null
@column()
declare stripeCustomerId: string | null
@column()
declare stripeSubscriptionId: string | null
@column()
declare subscriptionStatus: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
@column.dateTime()
declare virtualNow: DateTime | null
}
export class PlanStepSchema extends BaseModel {
static $columns = ['body', 'createdAt', 'id', 'offsetDays', 'order', 'planId', 'requiresManualValidation', 'subject', 'tone', 'updatedAt'] as const
$columns = PlanStepSchema.$columns
@column()
declare body: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: string
@column()
declare offsetDays: number
@column()
declare order: number
@column()
declare planId: string
@column()
declare requiresManualValidation: boolean
@column()
declare subject: string
@column()
declare tone: 'amical' | 'courtois' | 'ferme' | 'mise_en_demeure'
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class PlanSchema extends BaseModel {
static $columns = ['createdAt', 'description', 'id', 'isDefault', 'name', 'organizationId', 'slug', 'thanksBody', 'thanksSubject', 'updatedAt'] as const
$columns = PlanSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare description: string
@column({ isPrimary: true })
declare id: string
@column()
declare isDefault: boolean
@column()
declare name: string
@column()
declare organizationId: string
@column()
declare slug: string | null
@column()
declare thanksBody: string | null
@column()
declare thanksSubject: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class PostSchema extends BaseModel {
static $columns = ['aiGenerated', 'aiTopicId', 'authorName', 'canonicalUrl', 'contentHtml', 'contentMd', 'createdAt', 'excerpt', 'heroImageAlt', 'heroImageUrl', 'id', 'noindex', 'ogImageUrl', 'publishedAt', 'readingTimeMinutes', 'slug', 'status', 'tags', 'title', 'updatedAt', 'wordCount'] as const
$columns = PostSchema.$columns
@column()
declare aiGenerated: boolean
@column()
declare aiTopicId: string | null
@column()
declare authorName: string
@column()
declare canonicalUrl: string | null
@column()
declare contentHtml: string
@column()
declare contentMd: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare excerpt: string
@column()
declare heroImageAlt: string | null
@column()
declare heroImageUrl: string | null
@column({ isPrimary: true })
declare id: string
@column()
declare noindex: boolean
@column()
declare ogImageUrl: string | null
@column.dateTime()
declare publishedAt: DateTime | null
@column()
declare readingTimeMinutes: number
@column()
declare slug: string
@column()
declare status: any
@column()
declare tags: any
@column()
declare title: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
@column()
declare wordCount: number
}
export class RefreshTokenSchema extends BaseModel {
static $columns = ['createdAt', 'expiresAt', 'hashedToken', 'id', 'ipAddress', 'lastUsedAt', 'revokedAt', 'updatedAt', 'userAgent', 'userId'] as const
$columns = RefreshTokenSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime()
declare expiresAt: DateTime
@column()
declare hashedToken: string
@column({ isPrimary: true })
declare id: string
@column()
declare ipAddress: string | null
@column.dateTime()
declare lastUsedAt: DateTime | null
@column.dateTime()
declare revokedAt: DateTime | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
@column()
declare userAgent: string | null
@column()
declare userId: string
}
export class RelanceTaskSchema extends BaseModel {
static $columns = ['createdAt', 'id', 'invoiceId', 'organizationId', 'planStepId', 'queueJobId', 'sendAt', 'sentAt', 'status', 'updatedAt'] as const
$columns = RelanceTaskSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: string
@column()
declare invoiceId: string
@column()
declare organizationId: string
@column()
declare planStepId: string
@column()
declare queueJobId: string | null
@column.dateTime()
declare sendAt: DateTime
@column.dateTime()
declare sentAt: DateTime | null
@column()
declare status: 'scheduled' | 'sent' | 'cancelled' | 'failed'
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class UserSchema extends BaseModel {
static $columns = ['createdAt', 'email', 'fullName', 'googleId', 'id', 'isAdmin', 'microsoftId', 'organizationId', 'password', 'signature', 'updatedAt'] as const
$columns = UserSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare email: string
@column()
declare fullName: string | null
@column()
declare googleId: string | null
@column({ isPrimary: true })
declare id: string
@column()
declare isAdmin: boolean
@column()
declare microsoftId: string | null
@column()
declare organizationId: string | null
@column({ serializeAs: null })
declare password: string | null
@column()
declare signature: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}