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