1 {-# LANGUAGE OverloadedLists #-}
2 {-# LANGUAGE PolyKinds #-}
4 {-# LANGUAGE UndecidableInstances #-}
5 {-# OPTIONS_GHC -Wno-orphans #-}
7 module Literate.Invoice.HTML where
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
28 class HTMLIOable a where
29 htmlIO :: a -> IO Html
31 instance Doc.ToBlock InvoiceType where
33 InvoiceTypeProForma -> "pro forma"
34 InvoiceTypeSale -> "sale"
35 InvoiceTypeVoucher -> "voucher"
36 instance Doc.ToBlock InvoiceItemType where
38 InvoiceItemTypeItem -> "item"
39 InvoiceItemTypeService -> "service"
41 instance (ToMarkup (Quantity qf), UnitShowS unit) => ToMarkup (Amount qf unit) where
43 (amountQuantity & HTML.toMarkup)
44 <> (if null unit then "" else "\x202F" <> (unit & toHtml))
47 instance (Doc.ToInline (Quantity qf), UnitShowS unit) => Doc.ToInline (Amount qf unit) where
49 (amountQuantity & Doc.toInline)
50 <> (if null unit then "" else "\x202F" <> (unit & Doc.toInline))
53 instance (Doc.ToBlock (Quantity qf), QuantFact qf, UnitShowS unit) => Doc.ToBlock (Amount qf unit) where
54 toBlock x = Doc.BlockPara $ x & Doc.toInline
56 instance QuantFact qf => ToMarkup (Quantity qf) where
61 & (Prelude.toRational >>> Prelude.fromRational :: Ratio Natural -> Double)
62 & ( `Printf.formatArg`
65 , fmtAlternate = False
70 quantisationFactor @qf
71 & (Prelude.fromIntegral :: _ -> Double)
79 instance QuantFact qf => Doc.ToInline (Quantity qf) where
80 toInline qty = Doc.toInline do
83 & (Prelude.toRational >>> Prelude.fromRational :: Ratio Natural -> Double)
84 & ( `Printf.formatArg`
87 , fmtAlternate = False
92 quantisationFactor @qf
93 & (Prelude.fromIntegral :: _ -> Double)
101 instance QuantFact qf => Doc.ToBlock (Quantity qf) where
102 toBlock x = Doc.BlockPara $ x & Doc.toInline
104 instance HTMLIOable (InvoiceId, Invoice) where
105 htmlIO (invId, inv) = do
106 -- FixMe(portability): this absolute path is not portable out of my system
107 dataPath <- Self.getDataDir <&> File.normalise
108 -- paperCSS <- dataPath </> "styles" </> "Paper.css" & BS.readFile <&> Text.decodeUtf8
109 -- invoiceCSS <- dataPath </> "styles" </> "Invoice.css" & BS.readFile <&> Text.decodeUtf8
110 let invSummary :: [InvoiceItem _ _] =
111 invoiceSummary (inv & invoiceRates) (inv & invoiceWorks)
112 & foldMap (foldMap pure)
113 let invSummaryQuantityTotal :: Accounting.Quantity 100 =
114 invSummary <&> invoiceItemQuantity <&> Accounting.amountQuantity & Accounting.sumQuantities & fromMaybe 0
115 let invTitle :: Doc.Inline =
117 List.intersperse " - " $
119 [ case inv & invoiceIssuer & orgEntity & entityName of
121 Just n -> [Doc.toInline n]
122 , [inv & invoiceEmittedOn & Doc.toInline]
123 , ["Invoice #" <> (invId & Doc.toInline)]
124 , inv & invoiceOrders
130 { documentTitle = invTitle
131 , documentAttachments =
133 [ "styles/Document.css"
134 , "styles/Invoice.css"
143 { Doc.pageSection = Just $ invTitle
146 { Doc.flexDirection = Doc.FlexDirectionColumn
147 , Doc.flexGap = 0.5 & Doc.cm
149 [ "Invoice" & Doc.classes ["title"]
151 ( [ ["InvoiceIdentifier" := invId & Doc.toInline & Doc.toBlock]
152 , ["InvoiceType" := invId & invoiceIdType & Doc.toBlock]
153 , ["InvoiceOrders" := inv & invoiceOrders <&> (\x -> "-" := Doc.toBlock x) & Doc.List & Doc.toBlock]
154 , ["InvoiceEmittedOn" := inv & invoiceEmittedOn & Doc.toBlock]
155 , ["InvoicePaymentDueBefore" := inv & invoicePaymentDueBefore & Doc.toBlock]
161 & (\x -> x{Doc.containerClasses = ["invoice-headers"]})
166 [ "Seller" := inv & invoiceIssuer
167 , "Buyer" := inv & invoiceRecipient
173 x{Doc.containerClasses = ["invoice-from-to"]}
176 [ "Grand totals" & Doc.classes ["title"]
182 , "To pay (excl. taxes)"
187 { Doc.tableCellJustify = Doc.JustifyCenter
188 , Doc.tableCellContent =
190 <&> invoiceItemPeriod
194 { periodBeginning = min (x & periodBeginning) (y & periodBeginning)
195 , periodEnd = max (x & periodEnd) (y & periodEnd)
199 [ period & periodBeginning & Doc.toInline & Doc.BlockPara
200 , period & periodEnd & Doc.toInline & Doc.BlockPara
204 { Doc.tableCellJustify = Doc.JustifyCenter
205 , Doc.tableCellContent =
207 [ itm & invoiceItemQuantity
214 { Doc.tableCellJustify = Doc.JustifyCenter
215 , Doc.tableCellContent =
217 [ itm & invoiceItemTotal
230 [ "Mandatory legal notices" & Doc.classes ["title"]
233 InvoiceMentionTVANonApplicable ->
234 -- "TVA non applicable, art. 293 B du code général des impôts."
235 "—" := "VAT not applicable, art. 293 B of the French General Tax Code."
236 InvoiceMentionIndemnitéForfaitaire ->
238 [ -- "Indemnité forfaitaire pour frais de recouvrement en cas de retard de paiement\x202F: "
239 "Fixed compensation for recovery costs in case of late payment: "
240 , (40 :: Accounting.Amount 100 (Accounting.UnitName "€"))
244 InvoiceMentionIndemnitéTaux rate ->
246 [ -- "Taux des pénalités pour retard de paiement exigibles à compter du "
247 "Late payment penalty rate (applicable from "
248 , inv & invoicePaymentDueBefore & Time.addLocalTime Time.nominalDay & Doc.toInline
251 , rate & Doc.toInline
252 , -- , " × montant impayé × nombre de jours de retard / 365.25"
253 " × unpaid amount × number of days late / 365.25"
256 | mention <- inv & invoiceMentions
262 <&> \blk -> Doc.flexItem{Doc.flexItemContent = blk}
267 , invSummary & ol1 & chunksOf 15 <&> \invItemsChunk ->
269 { Doc.pageSection = Just $ invTitle <> " — Summary"
272 { Doc.flexDirection = Doc.FlexDirectionColumn
273 , Doc.flexGap = 0.5 & Doc.cm
276 { Doc.flexItemContent =
277 [ [ ("Summary" :: Text) & Doc.toBlock & Doc.classes ["title"]
278 | invItemsChunk & headMaybe & maybe False (fst >>> (== 1))
283 { Doc.tableTemplate =
284 [ Doc.LengthRelative $ Doc.LengthRelativeMaxContent
285 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
286 , Doc.LengthRelative $ 1 & Doc.fr
287 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
288 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
289 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
290 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
291 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
293 , Doc.tableRowsEvenOdd = True
302 { Doc.tableCellContent =
303 [ "Rate" & Doc.BlockPara
304 , "(excl.\xA0taxes.)" & Doc.BlockPara
306 , Doc.tableCellJustify = Doc.JustifyCenter
310 { Doc.tableCellContent =
311 [ "Total" & Doc.BlockPara
312 , "(excl.\xA0taxes.)" & Doc.BlockPara
314 , Doc.tableCellJustify = Doc.JustifyCenter
319 { Doc.tableCellContent = itemCount & Printf.printf "%i" & Doc.toBlock @String
320 , Doc.tableCellJustify = Doc.JustifyEnd
323 { Doc.tableCellContent = invItem & invoiceItemType & Doc.toBlock
324 , Doc.tableCellJustify = Doc.JustifyCenter
327 { Doc.tableCellContent = invItem & invoiceItemScope & pathToBlock
328 , Doc.tableCellJustify = Doc.JustifyStart
331 { Doc.tableCellContent = invItem & invoiceItemAction & pathToBlock
332 , Doc.tableCellJustify = Doc.JustifyStart
335 { Doc.tableCellContent =
336 [ invItem & invoiceItemPeriod & periodBeginning & Doc.toInline & Doc.BlockPara
337 , invItem & invoiceItemPeriod & periodEnd & Doc.toInline & Doc.BlockPara
339 , Doc.tableCellJustify = Doc.JustifyStart
342 { Doc.tableCellContent = invItem & invoiceItemRate & Doc.toBlock
343 , Doc.tableCellJustify = Doc.JustifyEnd
346 { Doc.tableCellContent =
347 [ invItem & invoiceItemQuantity & Doc.toBlock
348 , let (qtyPercent, _actualRate) =
350 (invSummaryQuantityTotal & Accounting.quantityToRatio & (100 Prelude./))
351 (invItem & invoiceItemQuantity & Accounting.amountQuantity)
353 & Accounting.quantityToRational
354 & (fromRational :: _ -> Double)
355 & (Printf.printf "(%02f%%)" :: _ -> String)
358 , Doc.tableCellJustify = Doc.JustifyEnd
361 { Doc.tableCellContent = invItem & invoiceItemTotal & Doc.toBlock
362 , Doc.tableCellJustify = Doc.JustifyEnd
365 | (itemCount, invItem) <- invItemsChunk
377 x{Doc.containerClasses = ["invoice-summary"]}
381 , inv & invoiceWorks & ol1 & chunksOf 15 <&> \worksChunk ->
383 { Doc.pageSection = Just $ invTitle <> " — Details"
386 { Doc.flexDirection = Doc.FlexDirectionColumn
387 , Doc.flexGap = 0.5 & Doc.cm
390 { Doc.flexItemContent =
391 [ [ ("Details" :: Text) & Doc.toBlock & Doc.classes ["title"]
392 | worksChunk & headMaybe & maybe False (fst >>> (== 1))
396 { Doc.tableTemplate =
397 [ Doc.LengthRelative $ Doc.LengthRelativeMaxContent
398 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
399 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
400 , Doc.LengthRelative $ 1 & Doc.fr
401 , Doc.LengthRelative $ 1 & Doc.fr
402 , Doc.LengthAbsolute $ 5 & Doc.cm
403 , Doc.LengthRelative $ 2 & Doc.fr
405 , Doc.tableRowsEvenOdd = True
418 { Doc.tableCellContent = workCount & Printf.printf "%i" & Doc.toBlock @String
419 , Doc.tableCellJustify = Doc.JustifyEnd
422 { Doc.tableCellContent = work & workDate & Doc.toBlock
423 , Doc.tableCellJustify = Doc.JustifyCenter
426 { Doc.tableCellContent =
427 work & workDuration & Doc.toBlock
428 , Doc.tableCellJustify = Doc.JustifyEnd
431 { Doc.tableCellContent = work & workScope & pathToBlock
432 , Doc.tableCellJustify = Doc.JustifyStart
435 { Doc.tableCellContent = work & workAction & pathToBlock
436 , Doc.tableCellJustify = Doc.JustifyStart
439 { Doc.tableCellContent =
441 | ref <- work & workReferences
445 , Doc.tableCellJustify = Doc.JustifyStart
448 { Doc.tableCellContent = work & workDescription & Doc.toBlock
449 , Doc.tableCellJustify = Doc.JustifyStart
452 | (workCount, work) <- worksChunk
462 & (\x -> x{Doc.containerClasses = ["invoice-details"]})
469 l & List.map \(pageIdx, pag) ->
471 { Doc.pageOrientation = Doc.PageOrientationPortrait
472 , Doc.pageSize = Doc.PageSizeA4
473 , Doc.pageNumber = Just pageIdx
474 , Doc.pageNumberTotal = Just $ l & List.length & fromIntegral
478 then Doc.PageSideLeft
479 else Doc.PageSideRight
487 & List.intersperse " / "
491 invoiceSummary :: _ -> _ -> Map [Text] (Map [Text] (InvoiceItem _ _))
492 invoiceSummary invRates works =
497 { invoiceItemScope = x & invoiceItemScope
498 , invoiceItemAction = x & invoiceItemAction
499 , invoiceItemPeriod =
501 { periodBeginning = min (x & invoiceItemPeriod & periodBeginning) (y & invoiceItemPeriod & periodBeginning)
502 , periodEnd = max (x & invoiceItemPeriod & periodEnd) (y & invoiceItemPeriod & periodEnd)
504 , invoiceItemQuantity = (x & invoiceItemQuantity) + (y & invoiceItemQuantity)
505 , invoiceItemType = x & invoiceItemType
506 , invoiceItemRate = x & invoiceItemRate
510 [ Map.singleton (work & workScope) $
511 Map.singleton (work & workAction) $
513 { invoiceItemScope = work & workScope
514 , invoiceItemAction = work & workAction
515 , invoiceItemType = InvoiceItemTypeService
516 , invoiceItemQuantity = work & workDuration
517 , invoiceItemPeriod =
519 { periodBeginning = work & workDate
520 , periodEnd = work & workDate
522 , invoiceItemRate = invRates & Map.lookup (work & workAction) & fromMaybe (errorShow ("missing action" :: Text, work & workAction))