]> Git — Sourcephile - tmp/julm/literate-invoice.git/blob - src/Literate/Invoice/HTML.hs
maint/correctness(Invoice): use sum type for InvoiceId
[tmp/julm/literate-invoice.git] / src / Literate / Invoice / HTML.hs
1 {-# LANGUAGE OverloadedLists #-}
2 {-# LANGUAGE PolyKinds #-}
3 -- For QuantFact
4 {-# LANGUAGE UndecidableInstances #-}
5 {-# OPTIONS_GHC -Wno-orphans #-}
6
7 module Literate.Invoice.HTML where
8
9 import Data.List qualified as List
10 import Data.Map.Strict qualified as Map
11 import Data.Time qualified as Time
12 import Literate.Accounting qualified as Accounting
13 import Literate.Accounting.Amount
14 import Literate.Accounting.Quantity
15 import Literate.Accounting.Unit
16 import Literate.Document.HTML
17 import Literate.Document.HTML qualified as HTML
18 import Literate.Document.Table qualified as Doc
19 import Literate.Document.Type qualified as Doc
20 import Literate.Invoice
21 import Literate.Organization
22 import Literate.Prelude
23 import Paths_literate_business qualified as Self
24 import System.FilePath.Posix qualified as File
25 import Text.Printf qualified as Printf
26 import Prelude qualified
27
28 class HTMLIOable a where
29 htmlIO :: a -> IO Html
30
31 instance Doc.ToBlock InvoiceType where
32 toBlock = \case
33 InvoiceTypeProForma -> "pro forma"
34 InvoiceTypeSale -> "sale"
35 InvoiceTypeVoucher -> "voucher"
36 instance Doc.ToBlock InvoiceItemType where
37 toBlock = \case
38 InvoiceItemTypeItem -> "item"
39 InvoiceItemTypeService -> "service"
40
41 instance (ToMarkup (Quantity qf), UnitShowS unit) => ToMarkup (Amount qf unit) where
42 toMarkup Amount{..} =
43 (amountQuantity & HTML.toMarkup)
44 <> (if null unit then "" else "\x202F" <> (unit & toHtml))
45 where
46 unit = unitShow @unit
47 instance (Doc.ToInline (Quantity qf), UnitShowS unit) => Doc.ToInline (Amount qf unit) where
48 toInline Amount{..} =
49 (amountQuantity & Doc.toInline)
50 <> (if null unit then "" else "\x202F" <> (unit & Doc.toInline))
51 where
52 unit = unitShow @unit
53 instance (Doc.ToBlock (Quantity qf), QuantFact qf, UnitShowS unit) => Doc.ToBlock (Amount qf unit) where
54 toBlock x = Doc.BlockPara $ x & Doc.toInline
55
56 instance QuantFact qf => ToMarkup (Quantity qf) where
57 toMarkup qty = do
58 toHtml $
59 qty
60 & quantityToRatio @qf
61 & (Prelude.toRational >>> Prelude.fromRational :: Ratio Natural -> Double)
62 & ( `Printf.formatArg`
63 Printf.FieldFormat
64 { fmtAdjust = Nothing
65 , fmtAlternate = False
66 , fmtChar = 'f'
67 , fmtModifiers = ""
68 , fmtPrecision =
69 Just $
70 quantisationFactor @qf
71 & (Prelude.fromIntegral :: _ -> Double)
72 & Prelude.logBase 10
73 & Prelude.floor
74 , fmtSign = Nothing
75 , fmtWidth = Nothing
76 }
77 )
78 & ($ "")
79 instance QuantFact qf => Doc.ToInline (Quantity qf) where
80 toInline qty = Doc.toInline do
81 qty
82 & quantityToRatio @qf
83 & (Prelude.toRational >>> Prelude.fromRational :: Ratio Natural -> Double)
84 & ( `Printf.formatArg`
85 Printf.FieldFormat
86 { fmtAdjust = Nothing
87 , fmtAlternate = False
88 , fmtChar = 'f'
89 , fmtModifiers = ""
90 , fmtPrecision =
91 Just $
92 quantisationFactor @qf
93 & (Prelude.fromIntegral :: _ -> Double)
94 & Prelude.logBase 10
95 & Prelude.floor
96 , fmtSign = Nothing
97 , fmtWidth = Nothing
98 }
99 )
100 & ($ "")
101 instance QuantFact qf => Doc.ToBlock (Quantity qf) where
102 toBlock x = Doc.BlockPara $ x & Doc.toInline
103
104 invoiceIdInline ::
105 GetOrganization entId =>
106 GetEntity entId =>
107 Enum invId =>
108 Doc.ToInline entId =>
109 Invoice entId invId ->
110 Doc.Inline
111 invoiceIdInline inv =
112 [ "org"
113 , inv
114 & invoiceRecipient
115 & getOrganization
116 & orgEntity
117 & getEntity
118 & entityId
119 & Doc.toInline
120 , case inv & invoiceType of
121 InvoiceTypeProForma -> "pro-forma"
122 InvoiceTypeSale -> "sale"
123 InvoiceTypeVoucher -> "voucher"
124 , inv & invoiceId & fromEnum & show & Doc.toInline
125 ]
126 & Doc.Inlines
127
128 instance
129 ( GetOrganization entId
130 , GetEntity entId
131 , Enum entId
132 , Enum invId
133 , Doc.ToInline entId
134 ) =>
135 HTMLIOable (Invoice entId invId)
136 where
137 htmlIO inv = do
138 -- FixMe(portability): this absolute path is not portable out of my system
139 dataPath <- Self.getDataDir <&> File.normalise
140 -- paperCSS <- dataPath </> "styles" </> "Paper.css" & BS.readFile <&> Text.decodeUtf8
141 -- invoiceCSS <- dataPath </> "styles" </> "Invoice.css" & BS.readFile <&> Text.decodeUtf8
142 let invSummary :: [InvoiceItem _ _] =
143 invoiceSummary (inv & invoiceRates) (inv & invoiceWorks)
144 & foldMap (foldMap pure)
145 let invSummaryQuantityTotal :: Accounting.Quantity 100 =
146 invSummary <&> invoiceItemQuantity <&> Accounting.amountQuantity & Accounting.sumQuantities & fromMaybe 0
147 let invTitle :: Doc.Inline =
148 Doc.toInline $
149 List.intersperse " - " $
150 mconcat
151 [ case inv
152 & invoiceIssuer
153 & getOrganization
154 & orgEntity
155 & getEntity
156 & entityName of
157 Nothing -> []
158 Just n -> [Doc.toInline n]
159 , [inv & invoiceEmittedOn & Doc.toInline]
160 , ["Invoice #" <> (inv & invoiceIdInline)]
161 , inv & invoiceOrders
162 ]
163 return $
164 toHtml $
165 dataPath :=
166 Doc.Document
167 { documentTitle = invTitle
168 , documentAttachments =
169 [ "css" :=
170 [ "styles/Document.css"
171 , "styles/Invoice.css"
172 , "styles/List.css"
173 , "styles/Paper.css"
174 , "styles/Table.css"
175 ]
176 ]
177 , documentPages =
178 [
179 [ Doc.page
180 { Doc.pageSection = Just $ invTitle
181 , Doc.pageContent =
182 Doc.flex
183 { Doc.flexDirection = Doc.FlexDirectionColumn
184 , Doc.flexGap = 0.5 & Doc.cm
185 , Doc.flexItems =
186 [ "Invoice" & Doc.classes ["title"]
187 , Doc.section
188 ( [ ["InvoiceIdentifier" := inv & invoiceIdInline & Doc.toBlock]
189 , ["InvoiceType" := inv & invoiceType & Doc.toBlock]
190 , ["InvoiceOrders" := inv & invoiceOrders <&> (\x -> "-" := Doc.toBlock x) & Doc.List & Doc.toBlock]
191 , ["InvoiceEmittedOn" := inv & invoiceEmittedOn & Doc.toBlock]
192 , ["InvoicePaymentDueBefore" := inv & invoicePaymentDueBefore & Doc.toBlock]
193 ]
194 & mconcat
195 & Doc.Dict
196 & Doc.toBlock
197 )
198 & (\x -> x{Doc.containerClasses = ["invoice-headers"]})
199 & Doc.BlockDiv
200 , Doc.section
201 [ Organization
202 { orgEntity = toEnum 0 -- Explanation: ununused placeholder
203 , orgParts =
204 [ "Seller" := inv & invoiceIssuer & getOrganization
205 , "Buyer" := inv & invoiceRecipient & getOrganization
206 ]
207 }
208 & Doc.toBlock
209 ]
210 & \x ->
211 x{Doc.containerClasses = ["invoice-from-to"]}
212 & Doc.BlockDiv
213 , Doc.section
214 [ "Grand totals" & Doc.classes ["title"]
215 , Doc.table
216 { Doc.tableHeads =
217 Just
218 [ "Time frame"
219 , "Workload"
220 , "To pay (excl. taxes)"
221 ]
222 , Doc.tableRows =
223 [
224 [ Doc.tableCell
225 { Doc.tableCellJustify = Doc.JustifyCenter
226 , Doc.tableCellContent =
227 invSummary
228 <&> invoiceItemPeriod
229 & List.foldr1
230 ( \x y ->
231 Period
232 { periodBeginning = min (x & periodBeginning) (y & periodBeginning)
233 , periodEnd = max (x & periodEnd) (y & periodEnd)
234 }
235 )
236 & \period ->
237 [ period & periodBeginning & Doc.toInline & Doc.BlockPara
238 , period & periodEnd & Doc.toInline & Doc.BlockPara
239 ]
240 }
241 , Doc.tableCell
242 { Doc.tableCellJustify = Doc.JustifyCenter
243 , Doc.tableCellContent =
244 sumAmounts
245 [ itm & invoiceItemQuantity
246 | itm <- invSummary
247 ]
248 & fromMaybe 0
249 & Doc.toBlock
250 }
251 , Doc.tableCell
252 { Doc.tableCellJustify = Doc.JustifyCenter
253 , Doc.tableCellContent =
254 sumAmounts
255 [ itm & invoiceItemTotal
256 | itm <- invSummary
257 ]
258 & fromMaybe 0
259 & Doc.toBlock
260 }
261 ]
262 ]
263 }
264 & Doc.toBlock
265 ]
266 & Doc.BlockDiv
267 , Doc.section
268 [ "Mandatory legal notices" & Doc.classes ["title"]
269 , Doc.List
270 [ case mention of
271 InvoiceMentionTVANonApplicable ->
272 -- "TVA non applicable, art. 293 B du code général des impôts."
273 "—" := "VAT not applicable, art. 293 B of the French General Tax Code."
274 InvoiceMentionIndemnitéForfaitaire ->
275 "—" :=
276 [ -- "Indemnité forfaitaire pour frais de recouvrement en cas de retard de paiement\x202F: "
277 "Fixed compensation for recovery costs in case of late payment: "
278 , (40 :: Accounting.Amount 100 (Accounting.UnitName "€"))
279 & Doc.toInline
280 ]
281 & Doc.BlockPara
282 InvoiceMentionIndemnitéTaux rate ->
283 "—" :=
284 [ -- "Taux des pénalités pour retard de paiement exigibles à compter du "
285 "Late payment penalty rate (applicable from "
286 , inv & invoicePaymentDueBefore & Time.addLocalTime Time.nominalDay & Doc.toInline
287 , -- , "\x202F: "
288 "): "
289 , rate & Doc.toInline
290 , -- , " × montant impayé × nombre de jours de retard / 365.25"
291 " × unpaid amount × number of days late / 365.25"
292 ]
293 & Doc.BlockPara
294 | mention <- inv & invoiceMentions
295 ]
296 & Doc.toBlock
297 ]
298 & Doc.BlockDiv
299 ]
300 <&> \blk -> Doc.flexItem{Doc.flexItemContent = blk}
301 }
302 & Doc.toBlock
303 }
304 ]
305 , invSummary & ol1 & chunksOf 15 <&> \invItemsChunk ->
306 Doc.page
307 { Doc.pageSection = Just $ invTitle <> " — Summary"
308 , Doc.pageContent =
309 Doc.flex
310 { Doc.flexDirection = Doc.FlexDirectionColumn
311 , Doc.flexGap = 0.5 & Doc.cm
312 , Doc.flexItems =
313 [ Doc.flexItem
314 { Doc.flexItemContent =
315 [ [ ("Summary" :: Text) & Doc.toBlock & Doc.classes ["title"]
316 | invItemsChunk & headMaybe & maybe False (fst >>> (== 1))
317 ]
318 & Doc.toBlock
319 ,
320 [ Doc.table
321 { Doc.tableTemplate =
322 [ Doc.LengthRelative $ Doc.LengthRelativeMaxContent
323 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
324 , Doc.LengthRelative $ 1 & Doc.fr
325 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
326 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
327 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
328 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
329 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
330 ]
331 , Doc.tableRowsEvenOdd = True
332 , Doc.tableHeads =
333 Just
334 [ "#"
335 , "Type"
336 , "Scope"
337 , "Action"
338 , "Time frame"
339 , Doc.tableCell
340 { Doc.tableCellContent =
341 [ "Rate" & Doc.BlockPara
342 , "(excl.\xA0taxes.)" & Doc.BlockPara
343 ]
344 , Doc.tableCellJustify = Doc.JustifyCenter
345 }
346 , "Quantity"
347 , Doc.tableCell
348 { Doc.tableCellContent =
349 [ "Total" & Doc.BlockPara
350 , "(excl.\xA0taxes.)" & Doc.BlockPara
351 ]
352 , Doc.tableCellJustify = Doc.JustifyCenter
353 }
354 ]
355 , Doc.tableRows =
356 [ [ Doc.tableCell
357 { Doc.tableCellContent = itemCount & Printf.printf "%i" & Doc.toBlock @String
358 , Doc.tableCellJustify = Doc.JustifyEnd
359 }
360 , Doc.tableCell
361 { Doc.tableCellContent = invItem & invoiceItemType & Doc.toBlock
362 , Doc.tableCellJustify = Doc.JustifyCenter
363 }
364 , Doc.tableCell
365 { Doc.tableCellContent = invItem & invoiceItemScope & pathToBlock
366 , Doc.tableCellJustify = Doc.JustifyStart
367 }
368 , Doc.tableCell
369 { Doc.tableCellContent = invItem & invoiceItemAction & pathToBlock
370 , Doc.tableCellJustify = Doc.JustifyStart
371 }
372 , Doc.tableCell
373 { Doc.tableCellContent =
374 [ invItem & invoiceItemPeriod & periodBeginning & Doc.toInline & Doc.BlockPara
375 , invItem & invoiceItemPeriod & periodEnd & Doc.toInline & Doc.BlockPara
376 ]
377 , Doc.tableCellJustify = Doc.JustifyStart
378 }
379 , Doc.tableCell
380 { Doc.tableCellContent = invItem & invoiceItemRate & Doc.toBlock
381 , Doc.tableCellJustify = Doc.JustifyEnd
382 }
383 , Doc.tableCell
384 { Doc.tableCellContent =
385 [ invItem & invoiceItemQuantity & Doc.toBlock
386 , let (qtyPercent, _actualRate) =
387 Accounting.fraction
388 (invSummaryQuantityTotal & Accounting.quantityToRatio & (100 Prelude./))
389 (invItem & invoiceItemQuantity & Accounting.amountQuantity)
390 in qtyPercent
391 & Accounting.quantityToRational
392 & (fromRational :: _ -> Double)
393 & (Printf.printf "(%02f%%)" :: _ -> String)
394 & Doc.toBlock
395 ]
396 , Doc.tableCellJustify = Doc.JustifyEnd
397 }
398 , Doc.tableCell
399 { Doc.tableCellContent = invItem & invoiceItemTotal & Doc.toBlock
400 , Doc.tableCellJustify = Doc.JustifyEnd
401 }
402 ]
403 | (itemCount, invItem) <- invItemsChunk
404 ]
405 }
406 & Doc.toBlock
407 ]
408 ]
409 }
410 ]
411 }
412 & Doc.toBlock
413 & Doc.section
414 & ( \x ->
415 x{Doc.containerClasses = ["invoice-summary"]}
416 )
417 & Doc.toBlock
418 }
419 , inv & invoiceWorks & ol1 & chunksOf 15 <&> \worksChunk ->
420 Doc.page
421 { Doc.pageSection = Just $ invTitle <> " — Details"
422 , Doc.pageContent =
423 Doc.flex
424 { Doc.flexDirection = Doc.FlexDirectionColumn
425 , Doc.flexGap = 0.5 & Doc.cm
426 , Doc.flexItems =
427 [ Doc.flexItem
428 { Doc.flexItemContent =
429 [ [ ("Details" :: Text) & Doc.toBlock & Doc.classes ["title"]
430 | worksChunk & headMaybe & maybe False (fst >>> (== 1))
431 ]
432 & Doc.toBlock
433 , Doc.table
434 { Doc.tableTemplate =
435 [ Doc.LengthRelative $ Doc.LengthRelativeMaxContent
436 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
437 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
438 , Doc.LengthRelative $ 1 & Doc.fr
439 , Doc.LengthRelative $ 1 & Doc.fr
440 , Doc.LengthAbsolute $ 5 & Doc.cm
441 , Doc.LengthRelative $ 2 & Doc.fr
442 ]
443 , Doc.tableRowsEvenOdd = True
444 , Doc.tableHeads =
445 Just
446 [ "#"
447 , "Date"
448 , "Duration"
449 , "Scope"
450 , "Action"
451 , "References"
452 , "Description"
453 ]
454 , Doc.tableRows =
455 [ [ Doc.tableCell
456 { Doc.tableCellContent = workCount & Printf.printf "%i" & Doc.toBlock @String
457 , Doc.tableCellJustify = Doc.JustifyEnd
458 }
459 , Doc.tableCell
460 { Doc.tableCellContent = work & workDate & Doc.toBlock
461 , Doc.tableCellJustify = Doc.JustifyCenter
462 }
463 , Doc.tableCell
464 { Doc.tableCellContent =
465 work & workDuration & Doc.toBlock
466 , Doc.tableCellJustify = Doc.JustifyEnd
467 }
468 , Doc.tableCell
469 { Doc.tableCellContent = work & workScope & pathToBlock
470 , Doc.tableCellJustify = Doc.JustifyStart
471 }
472 , Doc.tableCell
473 { Doc.tableCellContent = work & workAction & pathToBlock
474 , Doc.tableCellJustify = Doc.JustifyStart
475 }
476 , Doc.tableCell
477 { Doc.tableCellContent =
478 [ ref & Doc.toInline
479 | ref <- work & workReferences
480 ]
481 & Doc.toInline
482 & Doc.toBlock
483 , Doc.tableCellJustify = Doc.JustifyStart
484 }
485 , Doc.tableCell
486 { Doc.tableCellContent = work & workDescription & Doc.toBlock
487 , Doc.tableCellJustify = Doc.JustifyStart
488 }
489 ]
490 | (workCount, work) <- worksChunk
491 ]
492 }
493 & Doc.toBlock
494 ]
495 }
496 ]
497 }
498 & Doc.toBlock
499 & Doc.section
500 & (\x -> x{Doc.containerClasses = ["invoice-details"]})
501 & Doc.toBlock
502 }
503 ]
504 & mconcat
505 & ol1
506 & \l ->
507 l & List.map \(pageIdx, pag) ->
508 pag
509 { Doc.pageOrientation = Doc.PageOrientationPortrait
510 , Doc.pageSize = Doc.PageSizeA4
511 , Doc.pageNumber = Just pageIdx
512 , Doc.pageNumberTotal = Just $ l & List.length & fromIntegral
513 , Doc.pageSide =
514 Just $
515 if pageIdx & even
516 then Doc.PageSideLeft
517 else Doc.PageSideRight
518 }
519 }
520 where
521 pathToBlock segs =
522 [ seg & Doc.toInline
523 | seg <- segs
524 ]
525 & List.intersperse " / "
526 & Doc.toInline
527 & Doc.toBlock
528
529 invoiceSummary :: _ -> _ -> Map [Text] (Map [Text] (InvoiceItem _ _))
530 invoiceSummary invRates works =
531 Map.unionsWith
532 ( Map.unionWith
533 ( \x y ->
534 InvoiceItem
535 { invoiceItemScope = x & invoiceItemScope
536 , invoiceItemAction = x & invoiceItemAction
537 , invoiceItemPeriod =
538 Period
539 { periodBeginning = min (x & invoiceItemPeriod & periodBeginning) (y & invoiceItemPeriod & periodBeginning)
540 , periodEnd = max (x & invoiceItemPeriod & periodEnd) (y & invoiceItemPeriod & periodEnd)
541 }
542 , invoiceItemQuantity = (x & invoiceItemQuantity) + (y & invoiceItemQuantity)
543 , invoiceItemType = x & invoiceItemType
544 , invoiceItemRate = x & invoiceItemRate
545 }
546 )
547 )
548 [ Map.singleton (work & workScope) $
549 Map.singleton (work & workAction) $
550 InvoiceItem
551 { invoiceItemScope = work & workScope
552 , invoiceItemAction = work & workAction
553 , invoiceItemType = InvoiceItemTypeService
554 , invoiceItemQuantity = work & workDuration
555 , invoiceItemPeriod =
556 Period
557 { periodBeginning = work & workDate
558 , periodEnd = work & workDate
559 }
560 , invoiceItemRate = invRates & Map.lookup (work & workAction) & fromMaybe (errorShow ("missing action" :: Text, work & workAction))
561 }
562 | work <- works
563 ]