document-lock.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. /**
  2. * Tests for the invoice and quote edit lock.
  3. *
  4. * This guards money that has already left the workshop, so the cases that
  5. * matter most are the ones where the lock must NOT engage: a lock that catches
  6. * too much strands a document someone still has to correct, and the usual
  7. * outcome is that the whole feature gets switched off. Each rule is therefore
  8. * tested from both sides.
  9. */
  10. import { describe, it, expect } from 'vitest'
  11. import {
  12. DOCUMENT_LOCK_DEFAULTS,
  13. DocumentLockedError,
  14. assertEditable,
  15. invoiceLockState,
  16. invoicePaymentStatus,
  17. quoteLockState,
  18. quoteStatusKeepsLock,
  19. readDocumentLockSettings,
  20. type DocumentLockSettings,
  21. } from '@/lib/document-lock'
  22. const KEYS = {
  23. invoiceLockEnabled: 'workshop.invoiceLockEnabled',
  24. invoiceLockTrigger: 'workshop.invoiceLockTrigger',
  25. quoteLockEnabled: 'workshop.quoteLockEnabled',
  26. quoteLockTrigger: 'workshop.quoteLockTrigger',
  27. }
  28. const settingsFor = (over: Partial<DocumentLockSettings> = {}): DocumentLockSettings => ({
  29. ...DOCUMENT_LOCK_DEFAULTS,
  30. ...over,
  31. })
  32. const invoice = (over: Partial<Parameters<typeof invoiceLockState>[0]> = {}) => ({
  33. sentAt: null,
  34. manuallyPaid: false,
  35. totalAmount: 500,
  36. cost: 0,
  37. payments: [],
  38. editUnlockedAt: null,
  39. ...over,
  40. })
  41. describe('readDocumentLockSettings', () => {
  42. it('is off by default, so an upgrade changes nothing', () => {
  43. const read = readDocumentLockSettings({}, KEYS)
  44. expect(read.invoiceLockEnabled).toBe(false)
  45. expect(read.quoteLockEnabled).toBe(false)
  46. })
  47. it('defaults each trigger to the later, less disruptive point', () => {
  48. const read = readDocumentLockSettings({}, KEYS)
  49. expect(read.invoiceLockTrigger).toBe('paid')
  50. expect(read.quoteLockTrigger).toBe('accepted')
  51. })
  52. it('reads the stored strings', () => {
  53. const read = readDocumentLockSettings(
  54. {
  55. [KEYS.invoiceLockEnabled]: 'true',
  56. [KEYS.invoiceLockTrigger]: 'sent',
  57. [KEYS.quoteLockEnabled]: 'true',
  58. [KEYS.quoteLockTrigger]: 'sent',
  59. },
  60. KEYS
  61. )
  62. expect(read).toEqual({
  63. invoiceLockEnabled: true,
  64. invoiceLockTrigger: 'sent',
  65. quoteLockEnabled: true,
  66. quoteLockTrigger: 'sent',
  67. })
  68. })
  69. it('treats anything other than the string "true" as off', () => {
  70. for (const value of ['false', '1', 'yes', 'TRUE', '']) {
  71. expect(
  72. readDocumentLockSettings({ [KEYS.invoiceLockEnabled]: value }, KEYS).invoiceLockEnabled
  73. ).toBe(false)
  74. }
  75. })
  76. it('falls back to the default trigger for an unrecognised value', () => {
  77. // A hand-edited settings row must not take out every invoice page.
  78. const read = readDocumentLockSettings(
  79. { [KEYS.invoiceLockTrigger]: 'whenever', [KEYS.quoteLockTrigger]: 'rejected' },
  80. KEYS
  81. )
  82. expect(read.invoiceLockTrigger).toBe('paid')
  83. expect(read.quoteLockTrigger).toBe('accepted')
  84. })
  85. })
  86. describe('invoicePaymentStatus', () => {
  87. it('counts what has been recorded against the total', () => {
  88. expect(
  89. invoicePaymentStatus({ manuallyPaid: false, totalAmount: 500, cost: 0, payments: [] })
  90. ).toBe('unpaid')
  91. expect(
  92. invoicePaymentStatus({
  93. manuallyPaid: false,
  94. totalAmount: 500,
  95. cost: 0,
  96. payments: [{ amount: 200 }],
  97. })
  98. ).toBe('partial')
  99. expect(
  100. invoicePaymentStatus({
  101. manuallyPaid: false,
  102. totalAmount: 500,
  103. cost: 0,
  104. payments: [{ amount: 200 }, { amount: 300 }],
  105. })
  106. ).toBe('paid')
  107. })
  108. it('treats an overpayment as paid', () => {
  109. expect(
  110. invoicePaymentStatus({
  111. manuallyPaid: false,
  112. totalAmount: 500,
  113. cost: 0,
  114. payments: [{ amount: 600 }],
  115. })
  116. ).toBe('paid')
  117. })
  118. it('honours a manual mark regardless of what is recorded', () => {
  119. expect(
  120. invoicePaymentStatus({ manuallyPaid: true, totalAmount: 500, cost: 0, payments: [] })
  121. ).toBe('paid')
  122. })
  123. it('falls back to cost for records predating itemised totals', () => {
  124. expect(
  125. invoicePaymentStatus({
  126. manuallyPaid: false,
  127. totalAmount: 0,
  128. cost: 400,
  129. payments: [{ amount: 400 }],
  130. })
  131. ).toBe('paid')
  132. })
  133. it('calls an empty draft unpaid rather than settled', () => {
  134. // Otherwise every zero-total draft locks itself the moment the setting is
  135. // turned on, which is the worst possible first impression of the feature.
  136. expect(
  137. invoicePaymentStatus({ manuallyPaid: false, totalAmount: 0, cost: 0, payments: [] })
  138. ).toBe('unpaid')
  139. })
  140. it('reads a missing payments list as nothing paid', () => {
  141. expect(invoicePaymentStatus({ manuallyPaid: false, totalAmount: 500, cost: 0 })).toBe('unpaid')
  142. })
  143. it('treats a deposit on a zero-total draft as partial, not settled', () => {
  144. // A prepayment on work not yet priced must not lock the draft before any
  145. // lines exist; and the billing list, which requires a positive total to
  146. // call anything paid, must agree with the lock about the same record.
  147. expect(
  148. invoicePaymentStatus({
  149. manuallyPaid: false,
  150. totalAmount: 0,
  151. cost: 0,
  152. payments: [{ amount: 200 }],
  153. })
  154. ).toBe('partial')
  155. })
  156. })
  157. describe('invoiceLockState, with locking switched off', () => {
  158. it('leaves a sent and fully paid invoice editable', () => {
  159. const state = invoiceLockState(
  160. invoice({ sentAt: new Date('2026-01-01'), manuallyPaid: true }),
  161. settingsFor({ invoiceLockEnabled: false })
  162. )
  163. expect(state).toEqual({ locked: false, reason: null, unlockedAt: null })
  164. })
  165. })
  166. describe('invoiceLockState, locking when sent', () => {
  167. const settings = settingsFor({ invoiceLockEnabled: true, invoiceLockTrigger: 'sent' })
  168. it('locks once the invoice has reached the customer', () => {
  169. const state = invoiceLockState(invoice({ sentAt: new Date('2026-01-01') }), settings)
  170. expect(state.locked).toBe(true)
  171. expect(state.reason).toBe('sent')
  172. })
  173. it('leaves an unsent invoice editable, however large', () => {
  174. expect(invoiceLockState(invoice({ totalAmount: 99999 }), settings).locked).toBe(false)
  175. })
  176. it('ignores payment entirely', () => {
  177. // Paid but never sent: this trigger is about the customer holding a copy.
  178. expect(invoiceLockState(invoice({ manuallyPaid: true }), settings).locked).toBe(false)
  179. })
  180. })
  181. describe('invoiceLockState, locking when paid', () => {
  182. const settings = settingsFor({ invoiceLockEnabled: true, invoiceLockTrigger: 'paid' })
  183. it('locks once the balance is settled', () => {
  184. const state = invoiceLockState(invoice({ payments: [{ amount: 500 }] }), settings)
  185. expect(state.locked).toBe(true)
  186. expect(state.reason).toBe('paid')
  187. })
  188. it('locks on a manual mark', () => {
  189. expect(invoiceLockState(invoice({ manuallyPaid: true }), settings).locked).toBe(true)
  190. })
  191. it('leaves a part-paid invoice editable', () => {
  192. // The outstanding balance is often exactly what is being discussed.
  193. expect(invoiceLockState(invoice({ payments: [{ amount: 200 }] }), settings).locked).toBe(false)
  194. })
  195. it('ignores sending entirely', () => {
  196. expect(invoiceLockState(invoice({ sentAt: new Date('2026-01-01') }), settings).locked).toBe(
  197. false
  198. )
  199. })
  200. it('leaves an empty draft editable', () => {
  201. expect(invoiceLockState(invoice({ totalAmount: 0, cost: 0 }), settings).locked).toBe(false)
  202. })
  203. })
  204. describe('invoiceLockState, after an owner or admin unlocks it', () => {
  205. const paidSettings = settingsFor({ invoiceLockEnabled: true, invoiceLockTrigger: 'paid' })
  206. const sentSettings = settingsFor({ invoiceLockEnabled: true, invoiceLockTrigger: 'sent' })
  207. const unlockedAt = new Date('2026-02-01')
  208. it('reopens the document and says when', () => {
  209. const state = invoiceLockState(
  210. invoice({ manuallyPaid: true, editUnlockedAt: unlockedAt }),
  211. paidSettings
  212. )
  213. expect(state).toEqual({ locked: false, reason: null, unlockedAt })
  214. })
  215. it('reopens a sent invoice that has not been sent again since', () => {
  216. const record = invoice({ sentAt: new Date('2026-01-01'), editUnlockedAt: unlockedAt })
  217. expect(invoiceLockState(record, sentSettings)).toEqual({
  218. locked: false,
  219. reason: null,
  220. unlockedAt,
  221. })
  222. })
  223. describe('and it is then sent again', () => {
  224. it('locks the corrected copy the customer now holds', () => {
  225. // The unlock was permission to correct that version. Issuing it again
  226. // spends it, or an invoice unlocked once would stay open for good.
  227. const record = invoice({ sentAt: new Date('2026-02-02'), editUnlockedAt: unlockedAt })
  228. expect(invoiceLockState(record, sentSettings)).toEqual({
  229. locked: true,
  230. reason: 'sent',
  231. unlockedAt: null,
  232. })
  233. })
  234. it('treats a send at the same instant as not superseding the unlock', () => {
  235. // Strictly later, so the ordering of two events in the same millisecond
  236. // cannot decide whether someone gets to finish their edit.
  237. const record = invoice({ sentAt: unlockedAt, editUnlockedAt: unlockedAt })
  238. expect(invoiceLockState(record, sentSettings).locked).toBe(false)
  239. })
  240. it('leaves the unlock standing under the "paid" trigger', () => {
  241. // Sending does not lock under this rule, and the invoice was already
  242. // paid when it was reopened, so nothing new has happened to it.
  243. const record = invoice({
  244. manuallyPaid: true,
  245. sentAt: new Date('2026-02-02'),
  246. editUnlockedAt: unlockedAt,
  247. })
  248. expect(invoiceLockState(record, paidSettings).locked).toBe(false)
  249. })
  250. })
  251. })
  252. describe('quoteLockState', () => {
  253. const accepted = settingsFor({ quoteLockEnabled: true, quoteLockTrigger: 'accepted' })
  254. const sent = settingsFor({ quoteLockEnabled: true, quoteLockTrigger: 'sent' })
  255. it('leaves everything editable while switched off', () => {
  256. expect(quoteLockState({ status: 'accepted' }, settingsFor()).locked).toBe(false)
  257. })
  258. describe('locking when accepted', () => {
  259. it('locks an accepted quote', () => {
  260. expect(quoteLockState({ status: 'accepted' }, accepted)).toEqual({
  261. locked: true,
  262. reason: 'accepted',
  263. unlockedAt: null,
  264. })
  265. })
  266. it('locks a converted quote, whose job is already running', () => {
  267. expect(quoteLockState({ status: 'converted' }, accepted).locked).toBe(true)
  268. })
  269. it('leaves a sent quote editable, because quotes get negotiated', () => {
  270. expect(quoteLockState({ status: 'sent' }, accepted).locked).toBe(false)
  271. })
  272. it('leaves drafts, rejections and expiries editable', () => {
  273. for (const status of ['draft', 'rejected', 'expired']) {
  274. expect(quoteLockState({ status }, accepted).locked).toBe(false)
  275. }
  276. })
  277. })
  278. describe('locking when sent', () => {
  279. it('locks a sent quote and everything past it', () => {
  280. for (const status of ['sent', 'accepted', 'converted']) {
  281. expect(quoteLockState({ status }, sent).locked).toBe(true)
  282. }
  283. })
  284. it('leaves a draft editable', () => {
  285. expect(quoteLockState({ status: 'draft' }, sent).locked).toBe(false)
  286. })
  287. it('leaves a rejected or expired quote editable, so it can be revised', () => {
  288. for (const status of ['rejected', 'expired']) {
  289. expect(quoteLockState({ status }, sent).locked).toBe(false)
  290. }
  291. })
  292. })
  293. it('reopens after an unlock', () => {
  294. const unlockedAt = new Date('2026-02-01')
  295. expect(quoteLockState({ status: 'accepted', editUnlockedAt: unlockedAt }, accepted)).toEqual({
  296. locked: false,
  297. reason: null,
  298. unlockedAt,
  299. })
  300. })
  301. describe('an unlock is spent when the quote is issued again', () => {
  302. // A quote's status survives a re-send — an accepted quote stays accepted —
  303. // so unlike an invoice the status cannot show that a corrected copy went
  304. // out. sentAt carries that instead, under both triggers.
  305. const unlockedAt = new Date('2026-02-01')
  306. it('locks the corrected copy once it is re-sent', () => {
  307. const record = {
  308. status: 'accepted',
  309. sentAt: new Date('2026-02-02'),
  310. editUnlockedAt: unlockedAt,
  311. }
  312. expect(quoteLockState(record, accepted)).toEqual({
  313. locked: true,
  314. reason: 'accepted',
  315. unlockedAt: null,
  316. })
  317. expect(quoteLockState(record, sent).locked).toBe(true)
  318. })
  319. it('leaves the unlock standing while nothing has been re-sent', () => {
  320. const record = {
  321. status: 'accepted',
  322. sentAt: new Date('2026-01-01'),
  323. editUnlockedAt: unlockedAt,
  324. }
  325. expect(quoteLockState(record, accepted)).toEqual({
  326. locked: false,
  327. reason: null,
  328. unlockedAt,
  329. })
  330. })
  331. it('treats a send at the same instant as not superseding the unlock', () => {
  332. const record = { status: 'accepted', sentAt: unlockedAt, editUnlockedAt: unlockedAt }
  333. expect(quoteLockState(record, accepted).locked).toBe(false)
  334. })
  335. })
  336. it('does not lock on an unknown status', () => {
  337. expect(quoteLockState({ status: 'something-new' }, sent).locked).toBe(false)
  338. })
  339. })
  340. describe('quoteStatusKeepsLock', () => {
  341. it('lets a quote locked on sending move between sent, accepted and converted', () => {
  342. for (const status of ['sent', 'accepted', 'converted']) {
  343. expect(quoteStatusKeepsLock('sent', status)).toBe(true)
  344. }
  345. })
  346. it('does not let a quote locked on sending go back to draft or on to rejected', () => {
  347. expect(quoteStatusKeepsLock('sent', 'draft')).toBe(false)
  348. expect(quoteStatusKeepsLock('sent', 'rejected')).toBe(false)
  349. })
  350. it('keeps a quote locked on acceptance to accepted and converted', () => {
  351. expect(quoteStatusKeepsLock('accepted', 'accepted')).toBe(true)
  352. expect(quoteStatusKeepsLock('accepted', 'converted')).toBe(true)
  353. expect(quoteStatusKeepsLock('accepted', 'sent')).toBe(false)
  354. expect(quoteStatusKeepsLock('accepted', 'draft')).toBe(false)
  355. })
  356. it('agrees with quoteLockState for every status the editor offers', () => {
  357. for (const trigger of ['sent', 'accepted'] as const) {
  358. const settings: DocumentLockSettings = {
  359. ...DOCUMENT_LOCK_DEFAULTS,
  360. quoteLockEnabled: true,
  361. quoteLockTrigger: trigger,
  362. }
  363. for (const status of ['draft', 'sent', 'accepted', 'rejected', 'converted']) {
  364. expect(quoteStatusKeepsLock(trigger, status), `${trigger} -> ${status}`).toBe(
  365. quoteLockState({ status }, settings).locked
  366. )
  367. }
  368. }
  369. })
  370. })
  371. describe('assertEditable', () => {
  372. it('passes an editable document through', () => {
  373. expect(() => assertEditable({ locked: false, reason: null, unlockedAt: null })).not.toThrow()
  374. })
  375. it('throws with the reason attached, so the message can name the rule', () => {
  376. try {
  377. assertEditable({ locked: true, reason: 'paid', unlockedAt: null })
  378. expect.unreachable('should have thrown')
  379. } catch (err) {
  380. expect(err).toBeInstanceOf(DocumentLockedError)
  381. expect((err as DocumentLockedError).reason).toBe('paid')
  382. }
  383. })
  384. })