Migrations :
- relance_tasks (uuid id, organization_id FK CASCADE [scope direct sans join], invoice_id FK CASCADE, plan_step_id FK RESTRICT, send_at, status ENUM scheduled/sent/cancelled/failed, sent_at, queue_job_id pour cancel via BullMQ.remove). Indexes (org,status), (invoice_id), (send_at).
- checkin_tasks (uuid id, org_id, invoice_id, send_at, token_hash unique [SHA-256 du HMAC, TTL 24h], status ENUM scheduled/sent/answered/expired, answer 'paid'|'still_pending'). Pas encore branché — flow check-in arrivera dans un commit séparé (cf. backend.md §13.3).
Schema rules : status enums + answer typés.
Models RelanceTask + CheckinTask avec belongsTo Invoice / PlanStep.
Service relance_scheduler.ts :
- scheduleRelancesForInvoice(invoice) : pour chaque step du plan, calcule sendAt = dueDate + offsetDays. Si sendAt < now (facture importée en retard), on programme à `now + 1min` plutôt que skip — l'utilisateur "rattrape" une dette de relance, l'envoi immédiat est cohérent. Crée la RelanceTask + enqueue BullMQ avec delay, retry 5x exponential, jobId = `relance:<taskId>` pour idempotency. Cancelle les tasks scheduled existantes avant de re-programmer (gestion changement de plan).
- cancelFutureRelances(invoiceId, trx) : appelé par mark-paid pour stopper la chaîne.
Service queue.ts :
- getQueue(name) singleton lazy par queue
- registerWorker(name, handler) avec concurrency 5, log failed/completed
- shutdownQueue() pour le terminating hook Adonis
start/queue.ts (preload) : registerWorker('relances', sendRelanceJob) seulement quand `app.getEnvironment() === 'web'` (pas en tests/REPL — pas de connexion Redis pendant Japa).
Job send_relance_job.ts :
- Idempotent : si task.status !== 'scheduled', no-op
- Hook critique : si invoice paid/cancelled entre-temps, task.status = cancelled
- Mise en demeure (step.requiresManualValidation) : on n'envoie PAS, on log un activity_event 'warning_drafted' (cf. CLAUDE.md → Principes : validation manuelle obligatoire)
- Sinon : sendRelanceEmail + task.status=sent + invoice.rubisEarned+1 + organizations.rubis_count+1 + activity_event 'relance_sent'. Si invoice.status='pending', passe en 'in_relance' (sortie de l'état silencieux).
Service mail_dispatcher.ts : sendRelanceEmail interpole step.subject/body via mini moteur Mustache-like (renderTemplate, services/template.ts) avec {{client.name}}/{{numero}}/{{amount}}/{{dueDate}}/{{signature}}, puis @adonisjs/mail.use(MAIL_DRIVER) → Mailpit en dev, Resend en prod. Texte brut V1.
Triggers branchés :
- InvoicesController.store : si planId, scheduleRelancesForInvoice après création
- ImportBatchesController.validateDraft : pareil
- InvoicesController.markPaid : cancelFutureRelances dans la même tx que le paiement
#jobs/* ajouté aux imports package.json. Adonisrc preload start/queue.ts.
Bruno : doc 05-Invoices/04 Create maj avec instructions pour tester l'envoi immédiat (dueDate dans le passé → relance à now+1min → email visible dans Mailpit http://localhost:8025).
93 lines
2.7 KiB
JSON
93 lines
2.7 KiB
JSON
{
|
|
"name": "@rubis/api",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"type": "module",
|
|
"license": "MIT",
|
|
"exports": {
|
|
"./data": "./.adonisjs/client/data.d.ts",
|
|
"./registry": "./.adonisjs/client/registry/index.ts"
|
|
},
|
|
"scripts": {
|
|
"start": "node bin/server.js",
|
|
"build": "node ace build",
|
|
"dev": "node ace serve --hmr",
|
|
"test": "node ace test",
|
|
"lint": "eslint .",
|
|
"format": "prettier --write .",
|
|
"typecheck": "tsc --noEmit"
|
|
},
|
|
"imports": {
|
|
"#controllers/*": "./app/controllers/*.js",
|
|
"#exceptions/*": "./app/exceptions/*.js",
|
|
"#models/*": "./app/models/*.js",
|
|
"#mails/*": "./app/mails/*.js",
|
|
"#services/*": "./app/services/*.js",
|
|
"#jobs/*": "./app/jobs/*.js",
|
|
"#listeners/*": "./app/listeners/*.js",
|
|
"#events/*": "./app/events/*.js",
|
|
"#generated/*": "./.adonisjs/server/*.js",
|
|
"#middleware/*": "./app/middleware/*.js",
|
|
"#transformers/*": "./app/transformers/*.js",
|
|
"#validators/*": "./app/validators/*.js",
|
|
"#providers/*": "./providers/*.js",
|
|
"#policies/*": "./app/policies/*.js",
|
|
"#abilities/*": "./app/abilities/*.js",
|
|
"#database/*": "./database/*.js",
|
|
"#tests/*": "./tests/*.js",
|
|
"#start/*": "./start/*.js",
|
|
"#config/*": "./config/*.js"
|
|
},
|
|
"devDependencies": {
|
|
"@adonisjs/assembler": "^8.4.0",
|
|
"@adonisjs/eslint-config": "^3.0.0",
|
|
"@adonisjs/prettier-config": "^1.4.5",
|
|
"@adonisjs/tsconfig": "^2.0.0",
|
|
"@japa/assert": "^4.2.0",
|
|
"@japa/plugin-adonisjs": "^5.2.0",
|
|
"@japa/runner": "^5.3.0",
|
|
"@poppinss/ts-exec": "^1.4.4",
|
|
"@types/luxon": "^3.7.1",
|
|
"@types/node": "~25.6.0",
|
|
"@types/pg": "^8.20.0",
|
|
"eslint": "^10.2.0",
|
|
"hot-hook": "^1.0.0",
|
|
"pino-pretty": "^13.1.3",
|
|
"prettier": "^3.8.2",
|
|
"typescript": "~6.0.2",
|
|
"youch": "^4.1.1"
|
|
},
|
|
"dependencies": {
|
|
"@adonisjs/auth": "^10.1.0",
|
|
"@adonisjs/bouncer": "^4.0.0",
|
|
"@adonisjs/core": "^7.3.1",
|
|
"@adonisjs/cors": "^3.0.0",
|
|
"@adonisjs/drive": "^4.0.0",
|
|
"@adonisjs/limiter": "^3.0.1",
|
|
"@adonisjs/lucid": "^22.4.2",
|
|
"@adonisjs/mail": "^10.2.0",
|
|
"@adonisjs/session": "^8.1.0",
|
|
"@adonisjs/shield": "^9.0.0",
|
|
"@aws-sdk/client-s3": "^3.1043.0",
|
|
"@aws-sdk/s3-request-presigner": "^3.1043.0",
|
|
"@japa/api-client": "^3.2.1",
|
|
"@tuyau/core": "^1.2.2",
|
|
"@vinejs/vine": "^4.3.1",
|
|
"better-sqlite3": "^12.9.0",
|
|
"bullmq": "^5.76.5",
|
|
"ioredis": "^5.10.1",
|
|
"luxon": "^3.7.2",
|
|
"pg": "^8.20.0",
|
|
"reflect-metadata": "^0.2.2"
|
|
},
|
|
"hotHook": {
|
|
"boundaries": [
|
|
"./app/controllers/**/*.ts",
|
|
"./app/middleware/*.ts",
|
|
"./app/transformers/**/*.ts",
|
|
"./app/validators/**/*.ts",
|
|
"./app/services/**/*.ts"
|
|
]
|
|
},
|
|
"prettier": "@adonisjs/prettier-config"
|
|
} |