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
105 ( GetOrganization entId
110 HTMLIOable (InvoiceId entId, Invoice entId)
112 htmlIO (invId, inv) = do
113 -- FixMe(portability): this absolute path is not portable out of my system
114 dataPath <- Self.getDataDir <&> File.normalise
115 -- paperCSS <- dataPath </> "styles" </> "Paper.css" & BS.readFile <&> Text.decodeUtf8
116 -- invoiceCSS <- dataPath </> "styles" </> "Invoice.css" & BS.readFile <&> Text.decodeUtf8
117 let invSummary :: [InvoiceItem _ _] =
118 invoiceSummary (inv & invoiceRates) (inv & invoiceWorks)
119 & foldMap (foldMap pure)
120 let invSummaryQuantityTotal :: Accounting.Quantity 100 =
121 invSummary <&> invoiceItemQuantity <&> Accounting.amountQuantity & Accounting.sumQuantities & fromMaybe 0
122 let invTitle :: Doc.Inline =
124 List.intersperse " - " $
133 Just n -> [Doc.toInline n]
134 , [inv & invoiceEmittedOn & Doc.toInline]
135 , ["Invoice #" <> (invId & Doc.toInline)]
136 , inv & invoiceOrders
142 { documentTitle = invTitle
143 , documentAttachments =
145 [ "styles/Document.css"
146 , "styles/Invoice.css"
155 { Doc.pageSection = Just $ invTitle
158 { Doc.flexDirection = Doc.FlexDirectionColumn
159 , Doc.flexGap = 0.5 & Doc.cm
161 [ "Invoice" & Doc.classes ["title"]
163 ( [ ["InvoiceIdentifier" := invId & Doc.toInline & Doc.toBlock]
164 , ["InvoiceType" := invId & invoiceIdType & Doc.toBlock]
165 , ["InvoiceOrders" := inv & invoiceOrders <&> (\x -> "-" := Doc.toBlock x) & Doc.List & Doc.toBlock]
166 , ["InvoiceEmittedOn" := inv & invoiceEmittedOn & Doc.toBlock]
167 , ["InvoicePaymentDueBefore" := inv & invoicePaymentDueBefore & Doc.toBlock]
173 & (\x -> x{Doc.containerClasses = ["invoice-headers"]})
177 { orgEntity = toEnum 0 -- Explanation: ununused placeholder
179 [ "Seller" := inv & invoiceIssuer & getOrganization
180 , "Buyer" := inv & invoiceRecipient & getOrganization
186 x{Doc.containerClasses = ["invoice-from-to"]}
189 [ "Grand totals" & Doc.classes ["title"]
195 , "To pay (excl. taxes)"
200 { Doc.tableCellJustify = Doc.JustifyCenter
201 , Doc.tableCellContent =
203 <&> invoiceItemPeriod
207 { periodBeginning = min (x & periodBeginning) (y & periodBeginning)
208 , periodEnd = max (x & periodEnd) (y & periodEnd)
212 [ period & periodBeginning & Doc.toInline & Doc.BlockPara
213 , period & periodEnd & Doc.toInline & Doc.BlockPara
217 { Doc.tableCellJustify = Doc.JustifyCenter
218 , Doc.tableCellContent =
220 [ itm & invoiceItemQuantity
227 { Doc.tableCellJustify = Doc.JustifyCenter
228 , Doc.tableCellContent =
230 [ itm & invoiceItemTotal
243 [ "Mandatory legal notices" & Doc.classes ["title"]
246 InvoiceMentionTVANonApplicable ->
247 -- "TVA non applicable, art. 293 B du code général des impôts."
248 "—" := "VAT not applicable, art. 293 B of the French General Tax Code."
249 InvoiceMentionIndemnitéForfaitaire ->
251 [ -- "Indemnité forfaitaire pour frais de recouvrement en cas de retard de paiement\x202F: "
252 "Fixed compensation for recovery costs in case of late payment: "
253 , (40 :: Accounting.Amount 100 (Accounting.UnitName "€"))
257 InvoiceMentionIndemnitéTaux rate ->
259 [ -- "Taux des pénalités pour retard de paiement exigibles à compter du "
260 "Late payment penalty rate (applicable from "
261 , inv & invoicePaymentDueBefore & Time.addLocalTime Time.nominalDay & Doc.toInline
264 , rate & Doc.toInline
265 , -- , " × montant impayé × nombre de jours de retard / 365.25"
266 " × unpaid amount × number of days late / 365.25"
269 | mention <- inv & invoiceMentions
275 <&> \blk -> Doc.flexItem{Doc.flexItemContent = blk}
280 , invSummary & ol1 & chunksOf 15 <&> \invItemsChunk ->
282 { Doc.pageSection = Just $ invTitle <> " — Summary"
285 { Doc.flexDirection = Doc.FlexDirectionColumn
286 , Doc.flexGap = 0.5 & Doc.cm
289 { Doc.flexItemContent =
290 [ [ ("Summary" :: Text) & Doc.toBlock & Doc.classes ["title"]
291 | invItemsChunk & headMaybe & maybe False (fst >>> (== 1))
296 { Doc.tableTemplate =
297 [ Doc.LengthRelative $ Doc.LengthRelativeMaxContent
298 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
299 , Doc.LengthRelative $ 1 & Doc.fr
300 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
301 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
302 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
303 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
304 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
306 , Doc.tableRowsEvenOdd = True
315 { Doc.tableCellContent =
316 [ "Rate" & Doc.BlockPara
317 , "(excl.\xA0taxes.)" & Doc.BlockPara
319 , Doc.tableCellJustify = Doc.JustifyCenter
323 { Doc.tableCellContent =
324 [ "Total" & Doc.BlockPara
325 , "(excl.\xA0taxes.)" & Doc.BlockPara
327 , Doc.tableCellJustify = Doc.JustifyCenter
332 { Doc.tableCellContent = itemCount & Printf.printf "%i" & Doc.toBlock @String
333 , Doc.tableCellJustify = Doc.JustifyEnd
336 { Doc.tableCellContent = invItem & invoiceItemType & Doc.toBlock
337 , Doc.tableCellJustify = Doc.JustifyCenter
340 { Doc.tableCellContent = invItem & invoiceItemScope & pathToBlock
341 , Doc.tableCellJustify = Doc.JustifyStart
344 { Doc.tableCellContent = invItem & invoiceItemAction & pathToBlock
345 , Doc.tableCellJustify = Doc.JustifyStart
348 { Doc.tableCellContent =
349 [ invItem & invoiceItemPeriod & periodBeginning & Doc.toInline & Doc.BlockPara
350 , invItem & invoiceItemPeriod & periodEnd & Doc.toInline & Doc.BlockPara
352 , Doc.tableCellJustify = Doc.JustifyStart
355 { Doc.tableCellContent = invItem & invoiceItemRate & Doc.toBlock
356 , Doc.tableCellJustify = Doc.JustifyEnd
359 { Doc.tableCellContent =
360 [ invItem & invoiceItemQuantity & Doc.toBlock
361 , let (qtyPercent, _actualRate) =
363 (invSummaryQuantityTotal & Accounting.quantityToRatio & (100 Prelude./))
364 (invItem & invoiceItemQuantity & Accounting.amountQuantity)
366 & Accounting.quantityToRational
367 & (fromRational :: _ -> Double)
368 & (Printf.printf "(%02f%%)" :: _ -> String)
371 , Doc.tableCellJustify = Doc.JustifyEnd
374 { Doc.tableCellContent = invItem & invoiceItemTotal & Doc.toBlock
375 , Doc.tableCellJustify = Doc.JustifyEnd
378 | (itemCount, invItem) <- invItemsChunk
390 x{Doc.containerClasses = ["invoice-summary"]}
394 , inv & invoiceWorks & ol1 & chunksOf 15 <&> \worksChunk ->
396 { Doc.pageSection = Just $ invTitle <> " — Details"
399 { Doc.flexDirection = Doc.FlexDirectionColumn
400 , Doc.flexGap = 0.5 & Doc.cm
403 { Doc.flexItemContent =
404 [ [ ("Details" :: Text) & Doc.toBlock & Doc.classes ["title"]
405 | worksChunk & headMaybe & maybe False (fst >>> (== 1))
409 { Doc.tableTemplate =
410 [ Doc.LengthRelative $ Doc.LengthRelativeMaxContent
411 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
412 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
413 , Doc.LengthRelative $ 1 & Doc.fr
414 , Doc.LengthRelative $ 1 & Doc.fr
415 , Doc.LengthAbsolute $ 5 & Doc.cm
416 , Doc.LengthRelative $ 2 & Doc.fr
418 , Doc.tableRowsEvenOdd = True
431 { Doc.tableCellContent = workCount & Printf.printf "%i" & Doc.toBlock @String
432 , Doc.tableCellJustify = Doc.JustifyEnd
435 { Doc.tableCellContent = work & workDate & Doc.toBlock
436 , Doc.tableCellJustify = Doc.JustifyCenter
439 { Doc.tableCellContent =
440 work & workDuration & Doc.toBlock
441 , Doc.tableCellJustify = Doc.JustifyEnd
444 { Doc.tableCellContent = work & workScope & pathToBlock
445 , Doc.tableCellJustify = Doc.JustifyStart
448 { Doc.tableCellContent = work & workAction & pathToBlock
449 , Doc.tableCellJustify = Doc.JustifyStart
452 { Doc.tableCellContent =
454 | ref <- work & workReferences
458 , Doc.tableCellJustify = Doc.JustifyStart
461 { Doc.tableCellContent = work & workDescription & Doc.toBlock
462 , Doc.tableCellJustify = Doc.JustifyStart
465 | (workCount, work) <- worksChunk
475 & (\x -> x{Doc.containerClasses = ["invoice-details"]})
482 l & List.map \(pageIdx, pag) ->
484 { Doc.pageOrientation = Doc.PageOrientationPortrait
485 , Doc.pageSize = Doc.PageSizeA4
486 , Doc.pageNumber = Just pageIdx
487 , Doc.pageNumberTotal = Just $ l & List.length & fromIntegral
491 then Doc.PageSideLeft
492 else Doc.PageSideRight
500 & List.intersperse " / "
504 invoiceSummary :: _ -> _ -> Map [Text] (Map [Text] (InvoiceItem _ _))
505 invoiceSummary invRates works =
510 { invoiceItemScope = x & invoiceItemScope
511 , invoiceItemAction = x & invoiceItemAction
512 , invoiceItemPeriod =
514 { periodBeginning = min (x & invoiceItemPeriod & periodBeginning) (y & invoiceItemPeriod & periodBeginning)
515 , periodEnd = max (x & invoiceItemPeriod & periodEnd) (y & invoiceItemPeriod & periodEnd)
517 , invoiceItemQuantity = (x & invoiceItemQuantity) + (y & invoiceItemQuantity)
518 , invoiceItemType = x & invoiceItemType
519 , invoiceItemRate = x & invoiceItemRate
523 [ Map.singleton (work & workScope) $
524 Map.singleton (work & workAction) $
526 { invoiceItemScope = work & workScope
527 , invoiceItemAction = work & workAction
528 , invoiceItemType = InvoiceItemTypeService
529 , invoiceItemQuantity = work & workDuration
530 , invoiceItemPeriod =
532 { periodBeginning = work & workDate
533 , periodEnd = work & workDate
535 , invoiceItemRate = invRates & Map.lookup (work & workAction) & fromMaybe (errorShow ("missing action" :: Text, work & workAction))