import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' import RelanceTask from '#models/relance_task' import CheckinTask from '#models/checkin_task' import Invoice from '#models/invoice' import Organization from '#models/organization' import { hashCheckinToken } from '#services/checkin_token' import { createTestUser, createTwoOrgs } from '../helpers/auth.js' import { body, type ApiOk, type ApiOkPaged } from '../helpers/response.js' import { DateTime } from 'luxon' type InvoiceShape = { id: string organizationId: string clientId: string numero: string amountTtcCents: number status: string rubisEarned: number paidAt: string | null } type ClientShape = { id: string; name: string } type PlanShape = { id: string; slug: string } async function createClient(client: any, headers: Record, name: string) { const r = await client .post('/api/v1/clients') .headers(headers) .json({ name, email: `${name.toLowerCase().replace(/\s+/g, '')}@spec.test` }) return body>(r).data } async function getStandardPlan(client: any, headers: Record) { const r = await client.get('/api/v1/plans/standard-30j').headers(headers) return body>(r).data } test.group('Invoices — POST /invoices', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) test('crée une facture (201) avec rubisEarned=1 (bonus saisie)', async ({ client, assert }) => { const { bearer } = await createTestUser() const c = await createClient(client, bearer, 'Boulangerie Spec') const r = await client.post('/api/v1/invoices').headers(bearer).json({ clientId: c.id, clientName: c.name, numero: 'F-2026-T01', amountTtcCents: 124000, issueDate: '2026-04-01T09:00:00.000Z', dueDate: '2026-05-01T09:00:00.000Z', }) r.assertStatus(201) const inv = body>(r).data assert.equal(inv.rubisEarned, 1) assert.equal(inv.status, 'pending') }) test('crée à la volée un client si nom non matché + email fourni', async ({ client, assert }) => { const { bearer, accessToken } = await createTestUser() const r = await client.post('/api/v1/invoices').headers(bearer).json({ clientName: 'Nouveau Client', clientEmail: 'nouveau@spec.test', numero: 'F-2026-T02', amountTtcCents: 50000, issueDate: '2026-04-01T09:00:00.000Z', dueDate: '2026-05-01T09:00:00.000Z', }) r.assertStatus(201) // Le client doit exister maintenant const list = await client.get('/api/v1/clients').headers(bearer) const names = body>>(list).data.map((c) => c.name) assert.include(names, 'Nouveau Client') // assert le token est bien valide assert.isString(accessToken) }) test('refuse création client à la volée sans email (422 client_email_required)', async ({ client, assert, }) => { const { bearer } = await createTestUser() const r = await client.post('/api/v1/invoices').headers(bearer).json({ clientName: 'Sans Email', numero: 'F-2026-T03', amountTtcCents: 50000, issueDate: '2026-04-01T09:00:00.000Z', dueDate: '2026-05-01T09:00:00.000Z', }) r.assertStatus(422) const errors = body<{ errors: Array<{ code: string }> }>(r).errors assert.equal(errors[0].code, 'client_email_required') }) test('schedule uniquement le check-in si planId est fourni', async ({ client, assert }) => { const { bearer } = await createTestUser() const c = await createClient(client, bearer, 'Avec Plan') const plan = await getStandardPlan(client, bearer) const r = await client.post('/api/v1/invoices').headers(bearer).json({ clientId: c.id, clientName: c.name, planId: plan.id, numero: 'F-2026-T04', amountTtcCents: 50000, issueDate: '2026-04-01T09:00:00.000Z', dueDate: '2026-05-01T09:00:00.000Z', }) r.assertStatus(201) const inv = body>(r).data const tasks = await RelanceTask.query().where('invoice_id', inv.id) const checkins = await CheckinTask.query().where('invoice_id', inv.id) assert.lengthOf(tasks, 0) assert.lengthOf(checkins, 1) assert.equal(checkins[0].status, 'scheduled') }) test('clic check-in pending programme les relances sans rafale passée', async ({ client, assert, }) => { const { bearer, org } = await createTestUser() const c = await createClient(client, bearer, 'Checkin Pending') const plan = await getStandardPlan(client, bearer) const dueDate = DateTime.now().minus({ days: 20 }).set({ hour: 9, minute: 0, second: 0 }) const created = await client .post('/api/v1/invoices') .headers(bearer) .json({ clientId: c.id, clientName: c.name, planId: plan.id, numero: 'F-2026-CHECKIN', amountTtcCents: 50000, issueDate: dueDate.minus({ days: 15 }).toISO(), dueDate: dueDate.toISO(), }) created.assertStatus(201) const inv = body>(created).data const plain = 'pending-token-spec' const checkin = await CheckinTask.query().where('invoice_id', inv.id).firstOrFail() checkin.tokenHash = hashCheckinToken(plain) checkin.status = 'sent' checkin.sentAt = DateTime.now() await checkin.save() const beforeTasks = await RelanceTask.query().where('invoice_id', inv.id) assert.lengthOf(beforeTasks, 0) const pending = await client.get(`/api/v1/checkin/${plain}/pending`) pending.assertStatus(200) const invoice = await Invoice.findOrFail(inv.id) const tasks = await RelanceTask.query() .where('invoice_id', inv.id) .preload('planStep') .orderBy('send_at', 'asc') assert.equal(invoice.organizationId, org.id) assert.lengthOf(tasks, 3) for (const t of tasks) assert.equal(t.status, 'scheduled') assert.isAtMost(Math.abs(tasks[0].sendAt.diffNow('minutes').minutes), 2) assert.isAbove(tasks[1].sendAt.toMillis(), tasks[0].sendAt.toMillis()) }) test('numéro unique par org (422 duplicate)', async ({ client }) => { const { bearer } = await createTestUser() const c = await createClient(client, bearer, 'Dup Test') const payload = { clientId: c.id, clientName: c.name, numero: 'F-2026-DUP', amountTtcCents: 50000, issueDate: '2026-04-01T09:00:00.000Z', dueDate: '2026-05-01T09:00:00.000Z', } await client.post('/api/v1/invoices').headers(bearer).json(payload) const r2 = await client.post('/api/v1/invoices').headers(bearer).json(payload) r2.assertStatus(422) }) }) test.group('Invoices — POST /invoices/:id/mark-paid', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) test('idempotent : 2e appel ne re-bumpe pas rubisEarned', async ({ client, assert }) => { const { bearer, org } = await createTestUser() const c = await createClient(client, bearer, 'Idem') const created = await client.post('/api/v1/invoices').headers(bearer).json({ clientId: c.id, clientName: c.name, numero: 'F-2026-T10', amountTtcCents: 100000, issueDate: '2026-04-01T09:00:00.000Z', dueDate: '2026-05-01T09:00:00.000Z', }) const id = body>(created).data.id const first = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(bearer) first.assertStatus(200) const afterFirst = body>(first).data assert.equal(afterFirst.status, 'paid') assert.equal(afterFirst.rubisEarned, 2) // 1 saisie + 1 encaissement const second = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(bearer) second.assertStatus(200) assert.equal(body>(second).data.rubisEarned, 2) // pas re-bumpé // Org rubisCount n'a été incrémenté qu'une fois const orgFresh = await Organization.findOrFail(org.id) assert.equal(orgFresh.rubisCount, 1) }) test('annule les RelanceTasks scheduled de la facture', async ({ client, assert }) => { const { bearer } = await createTestUser() const c = await createClient(client, bearer, 'Cancel Tasks') const plan = await getStandardPlan(client, bearer) const created = await client.post('/api/v1/invoices').headers(bearer).json({ clientId: c.id, clientName: c.name, planId: plan.id, numero: 'F-2026-T11', amountTtcCents: 100000, issueDate: '2026-04-01T09:00:00.000Z', dueDate: '2026-05-01T09:00:00.000Z', }) const id = body>(created).data.id const plain = 'cancel-token-spec' const checkin = await CheckinTask.query().where('invoice_id', id).firstOrFail() checkin.tokenHash = hashCheckinToken(plain) checkin.status = 'sent' checkin.sentAt = DateTime.now() await checkin.save() const pending = await client.get(`/api/v1/checkin/${plain}/pending`) pending.assertStatus(200) const beforeTasks = await RelanceTask.query().where('invoice_id', id) assert.lengthOf(beforeTasks, 3) await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(bearer) const afterTasks = await RelanceTask.query().where('invoice_id', id) for (const t of afterTasks) assert.equal(t.status, 'cancelled') }) test('cross-org : user B ne peut pas mark-paid une facture de A (404)', async ({ client }) => { const { a, b } = await createTwoOrgs() const c = await createClient(client, a.bearer, 'Of A') const created = await client.post('/api/v1/invoices').headers(a.bearer).json({ clientId: c.id, clientName: c.name, numero: 'F-2026-XO1', amountTtcCents: 50000, issueDate: '2026-04-01T09:00:00.000Z', dueDate: '2026-05-01T09:00:00.000Z', }) const id = body>(created).data.id const r = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(b.bearer) r.assertStatus(404) }) }) test.group('Invoices — GET /invoices', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) test('liste paginée + meta total/page', async ({ client, assert }) => { const { bearer } = await createTestUser() const c = await createClient(client, bearer, 'Liste Spec') for (let i = 0; i < 3; i++) { await client .post('/api/v1/invoices') .headers(bearer) .json({ clientId: c.id, clientName: c.name, numero: `F-2026-L${i}`, amountTtcCents: 10000 + i, issueDate: '2026-04-01T09:00:00.000Z', dueDate: '2026-05-01T09:00:00.000Z', }) } const r = await client.get('/api/v1/invoices').headers(bearer) r.assertStatus(200) const payload = body>(r) assert.equal(payload.meta.total, 3) assert.equal(payload.meta.page, 1) }) test('GET /invoices/counts agrège par status', async ({ client, assert }) => { const { bearer } = await createTestUser() const c = await createClient(client, bearer, 'Counts Spec') await client.post('/api/v1/invoices').headers(bearer).json({ clientId: c.id, clientName: c.name, numero: 'F-2026-C01', amountTtcCents: 10000, issueDate: '2026-04-01T09:00:00.000Z', dueDate: '2026-05-01T09:00:00.000Z', }) const r = await client.get('/api/v1/invoices/counts').headers(bearer) r.assertStatus(200) const c2 = body>(r).data assert.equal(c2.all, 1) assert.equal(c2.pending, 1) }) })