import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' import RelanceTask from '#models/relance_task' import Organization from '#models/organization' import { createTestUser, createTwoOrgs } from '../helpers/auth.js' import { body, type ApiOk, type ApiOkPaged } from '../helpers/response.js' 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 des RelanceTasks 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) // Le plan standard-30j a 3 steps assert.lengthOf(tasks, 3) for (const t of tasks) assert.equal(t.status, 'scheduled') }) 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 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) }) })