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 =>
108 Doc.ToInline entId =>
109 Invoice entId invId ->
111 invoiceIdInline inv =
120 , case inv & invoiceType of
121 InvoiceTypeProForma -> "pro-forma"
122 InvoiceTypeSale -> "sale"
123 InvoiceTypeVoucher -> "voucher"
124 , inv & invoiceId & fromEnum & show & Doc.toInline
129 ( GetOrganization entId
135 HTMLIOable (Invoice entId invId)
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 =
149 List.intersperse " - " $
158 Just n -> [Doc.toInline n]
159 , [inv & invoiceEmittedOn & Doc.toInline]
160 , ["Invoice #" <> (inv & invoiceIdInline)]
161 , inv & invoiceOrders
167 { documentTitle = invTitle
168 , documentAttachments =
170 [ "styles/Document.css"
171 , "styles/Invoice.css"
180 { Doc.pageSection = Just $ invTitle
183 { Doc.flexDirection = Doc.FlexDirectionColumn
184 , Doc.flexGap = 0.5 & Doc.cm
186 [ "Invoice" & Doc.classes ["title"]
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]
198 & (\x -> x{Doc.containerClasses = ["invoice-headers"]})
202 { orgEntity = toEnum 0 -- Explanation: ununused placeholder
204 [ "Seller" := inv & invoiceIssuer & getOrganization
205 , "Buyer" := inv & invoiceRecipient & getOrganization
211 x{Doc.containerClasses = ["invoice-from-to"]}
214 [ "Grand totals" & Doc.classes ["title"]
220 , "To pay (excl. taxes)"
225 { Doc.tableCellJustify = Doc.JustifyCenter
226 , Doc.tableCellContent =
228 <&> invoiceItemPeriod
232 { periodBeginning = min (x & periodBeginning) (y & periodBeginning)
233 , periodEnd = max (x & periodEnd) (y & periodEnd)
237 [ period & periodBeginning & Doc.toInline & Doc.BlockPara
238 , period & periodEnd & Doc.toInline & Doc.BlockPara
242 { Doc.tableCellJustify = Doc.JustifyCenter
243 , Doc.tableCellContent =
245 [ itm & invoiceItemQuantity
252 { Doc.tableCellJustify = Doc.JustifyCenter
253 , Doc.tableCellContent =
255 [ itm & invoiceItemTotal
268 [ "Mandatory legal notices" & Doc.classes ["title"]
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 ->
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 "€"))
282 InvoiceMentionIndemnitéTaux rate ->
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
289 , rate & Doc.toInline
290 , -- , " × montant impayé × nombre de jours de retard / 365.25"
291 " × unpaid amount × number of days late / 365.25"
294 | mention <- inv & invoiceMentions
300 <&> \blk -> Doc.flexItem{Doc.flexItemContent = blk}
305 , invSummary & ol1 & chunksOf 15 <&> \invItemsChunk ->
307 { Doc.pageSection = Just $ invTitle <> " — Summary"
310 { Doc.flexDirection = Doc.FlexDirectionColumn
311 , Doc.flexGap = 0.5 & Doc.cm
314 { Doc.flexItemContent =
315 [ [ ("Summary" :: Text) & Doc.toBlock & Doc.classes ["title"]
316 | invItemsChunk & headMaybe & maybe False (fst >>> (== 1))
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
331 , Doc.tableRowsEvenOdd = True
340 { Doc.tableCellContent =
341 [ "Rate" & Doc.BlockPara
342 , "(excl.\xA0taxes.)" & Doc.BlockPara
344 , Doc.tableCellJustify = Doc.JustifyCenter
348 { Doc.tableCellContent =
349 [ "Total" & Doc.BlockPara
350 , "(excl.\xA0taxes.)" & Doc.BlockPara
352 , Doc.tableCellJustify = Doc.JustifyCenter
357 { Doc.tableCellContent = itemCount & Printf.printf "%i" & Doc.toBlock @String
358 , Doc.tableCellJustify = Doc.JustifyEnd
361 { Doc.tableCellContent = invItem & invoiceItemType & Doc.toBlock
362 , Doc.tableCellJustify = Doc.JustifyCenter
365 { Doc.tableCellContent = invItem & invoiceItemScope & pathToBlock
366 , Doc.tableCellJustify = Doc.JustifyStart
369 { Doc.tableCellContent = invItem & invoiceItemAction & pathToBlock
370 , Doc.tableCellJustify = Doc.JustifyStart
373 { Doc.tableCellContent =
374 [ invItem & invoiceItemPeriod & periodBeginning & Doc.toInline & Doc.BlockPara
375 , invItem & invoiceItemPeriod & periodEnd & Doc.toInline & Doc.BlockPara
377 , Doc.tableCellJustify = Doc.JustifyStart
380 { Doc.tableCellContent = invItem & invoiceItemRate & Doc.toBlock
381 , Doc.tableCellJustify = Doc.JustifyEnd
384 { Doc.tableCellContent =
385 [ invItem & invoiceItemQuantity & Doc.toBlock
386 , let (qtyPercent, _actualRate) =
388 (invSummaryQuantityTotal & Accounting.quantityToRatio & (100 Prelude./))
389 (invItem & invoiceItemQuantity & Accounting.amountQuantity)
391 & Accounting.quantityToRational
392 & (fromRational :: _ -> Double)
393 & (Printf.printf "(%02f%%)" :: _ -> String)
396 , Doc.tableCellJustify = Doc.JustifyEnd
399 { Doc.tableCellContent = invItem & invoiceItemTotal & Doc.toBlock
400 , Doc.tableCellJustify = Doc.JustifyEnd
403 | (itemCount, invItem) <- invItemsChunk
415 x{Doc.containerClasses = ["invoice-summary"]}
419 , inv & invoiceWorks & ol1 & chunksOf 15 <&> \worksChunk ->
421 { Doc.pageSection = Just $ invTitle <> " — Details"
424 { Doc.flexDirection = Doc.FlexDirectionColumn
425 , Doc.flexGap = 0.5 & Doc.cm
428 { Doc.flexItemContent =
429 [ [ ("Details" :: Text) & Doc.toBlock & Doc.classes ["title"]
430 | worksChunk & headMaybe & maybe False (fst >>> (== 1))
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
443 , Doc.tableRowsEvenOdd = True
456 { Doc.tableCellContent = workCount & Printf.printf "%i" & Doc.toBlock @String
457 , Doc.tableCellJustify = Doc.JustifyEnd
460 { Doc.tableCellContent = work & workDate & Doc.toBlock
461 , Doc.tableCellJustify = Doc.JustifyCenter
464 { Doc.tableCellContent =
465 work & workDuration & Doc.toBlock
466 , Doc.tableCellJustify = Doc.JustifyEnd
469 { Doc.tableCellContent = work & workScope & pathToBlock
470 , Doc.tableCellJustify = Doc.JustifyStart
473 { Doc.tableCellContent = work & workAction & pathToBlock
474 , Doc.tableCellJustify = Doc.JustifyStart
477 { Doc.tableCellContent =
479 | ref <- work & workReferences
483 , Doc.tableCellJustify = Doc.JustifyStart
486 { Doc.tableCellContent = work & workDescription & Doc.toBlock
487 , Doc.tableCellJustify = Doc.JustifyStart
490 | (workCount, work) <- worksChunk
500 & (\x -> x{Doc.containerClasses = ["invoice-details"]})
507 l & List.map \(pageIdx, pag) ->
509 { Doc.pageOrientation = Doc.PageOrientationPortrait
510 , Doc.pageSize = Doc.PageSizeA4
511 , Doc.pageNumber = Just pageIdx
512 , Doc.pageNumberTotal = Just $ l & List.length & fromIntegral
516 then Doc.PageSideLeft
517 else Doc.PageSideRight
525 & List.intersperse " / "
529 invoiceSummary :: _ -> _ -> Map [Text] (Map [Text] (InvoiceItem _ _))
530 invoiceSummary invRates works =
535 { invoiceItemScope = x & invoiceItemScope
536 , invoiceItemAction = x & invoiceItemAction
537 , invoiceItemPeriod =
539 { periodBeginning = min (x & invoiceItemPeriod & periodBeginning) (y & invoiceItemPeriod & periodBeginning)
540 , periodEnd = max (x & invoiceItemPeriod & periodEnd) (y & invoiceItemPeriod & periodEnd)
542 , invoiceItemQuantity = (x & invoiceItemQuantity) + (y & invoiceItemQuantity)
543 , invoiceItemType = x & invoiceItemType
544 , invoiceItemRate = x & invoiceItemRate
548 [ Map.singleton (work & workScope) $
549 Map.singleton (work & workAction) $
551 { invoiceItemScope = work & workScope
552 , invoiceItemAction = work & workAction
553 , invoiceItemType = InvoiceItemTypeService
554 , invoiceItemQuantity = work & workDuration
555 , invoiceItemPeriod =
557 { periodBeginning = work & workDate
558 , periodEnd = work & workDate
560 , invoiceItemRate = invRates & Map.lookup (work & workAction) & fromMaybe (errorShow ("missing action" :: Text, work & workAction))