- Invoice totals with two VAT rates and the due date
- Break-even point and margin of safety
- Declining-balance depreciation schedule
- Late payment interest and recovery indemnity
- Bank reconciliation of account 512
- Benford test of expense claims
- Gaps and duplicates in the invoice numbering
- Aged receivables and doubtful debt allowance
- Reciprocal allocation of service department costs
- Cash rounding of tickets to 5 cents
Invoice totals with two VAT rates and the due date
An invoice dated 15 December 2026 has goods at 20% VAT and books at 5.5%. The script computes the VAT and the gross total, then the due date of a '30 days end of month' term, moved back to the last business day when it falls on a weekend.
#1. Invoice dated 2026-12-15, two VAT rates; -> "[ht20]" (+ 1250 340); -> "[ht55]" 180; -> "[vat]" (math round (+ (math percent [ht20] 20) (math percent [ht55] 5.5)) 2); -> "[ttc]" (math calc "[ht20]+[ht55]+[vat]"); #2. Term: 30 days end of month; -> "[due]" (date last_day_of_month (date add "2026-12-15" "DAY" 30)); #3. A weekend due date moves back to the last business day; -> "[paid]" (date add_business_days (date add_business_days [due] 1) -1); concat "{\"net\": " (+ [ht20] [ht55]) ", \"vat\": " [vat] ", \"gross\": " [ttc] ", \"term_end\": \"" [due] "\", \"weekend\": " (date is_weekend [due]) ", \"due\": \"" [paid] "\"}";
{"net": 1770, "vat": 327.9, "gross": 2097.9, "term_end": "2027-01-31", "weekend": 1, "due": "2027-01-29"}- Public holidays are not known by
date add_business_days: check them with a list anddate between - Late payment penalties:
date nb_daybetween the due date and the payment date, times the daily rate
Break-even point and margin of safety
A product sells 25 with a variable cost of 13.40 and 48000 of fixed costs a year. The script computes the unit contribution margin, the break-even volume and revenue, and the margin of safety against a budget of 5600 units.
#1. Costs and prices of the product; -> "[fixed]" 48000; -> "[price]" 25; -> "[var]" 13.4; -> "[budget]" 5600; #2. Contribution margin and break-even point; -> "[cm]" (math calc "[price]-[var]"); -> "[be]" (math ceil (math calc "[fixed]/[cm]")); #3. Margin of safety on the budgeted volume; -> "[mos]" (math round (math calc "([budget]-[be])/[budget]*100") 1); concat "Contribution margin " [cm] " per unit, " (math round (math calc "[cm]/[price]*100") 1) "% of the price. Break-even at " [be] " units = " (* [be] [price]) " in sales. Margin of safety " [mos] "%."
Contribution margin 11.6 per unit, 46.4% of the price. Break-even at 4138 units = 103450 in sales. Margin of safety 26.1%.
- Operating leverage: total contribution divided by operating profit at the budget volume
- Several products: weight the margins by the sales mix with
stat weighted_mean
Declining-balance depreciation schedule
A machine bought 20000 is depreciated over 5 years with the declining coefficient 1.75 (rate 35%). The script compares the first-year charge with the straight-line method and tables the net book value at the end of each year.
#1. Asset bought 20000, 5 years, declining coefficient 1.75; -> "[cost]" 20000; -> "[rate]" (math calc "1.75/5"); #2. Year 1 charge, declining vs straight line; -> "[y1]" (math calc "[cost]*[rate]"); -> "[sl]" (math calc "[cost]/5"); #3. Net book value at the end of each year; -> "[nbv]" (num table (concat "round(" [cost] "*(1-" [rate] ")^n*100)/100") "n" 0 5 1); concat "Year 1 charge: " [y1] " declining vs " [sl] " straight line. Net book value by year: " [nbv];
Year 1 charge: 7000 declining vs 4000 straight line. Net book value by year: [ [0,20000], [1,13000], [2,8450], [3,5492.5], [4,3570.13], [5,2320.58] ]
- Switch to straight line when the remaining value divided by the remaining years gives a higher charge
- Prorata temporis for the first year: multiply by
date nb_dayfrom the purchase date to year end, over 360
Late payment interest and recovery indemnity
On 1 November the accountant prepares the reminders of overdue B2B invoices. French law sets the rate at the ECB rate plus 10 points (2.15 + 10 = 12.15 percent here) on the days late, plus a fixed 40 EUR recovery indemnity per invoice. Invoices not yet due are skipped; every amount is exact.
#1. Unpaid B2B invoices on 2026-11-01, legal rate = ECB rate 2.15 + 10 points; -> "[today]" "2026-11-01"; -> "[rate]" (bd+ 2.15 10); json load "rx_ar" "[{\"inv\": \"F-2026-071\", \"client\": \"Batiman SARL\", \"ttc\": \"4860.00\", \"due\": \"2026-07-31\"}, {\"inv\": \"F-2026-088\", \"client\": \"Run Distribution\", \"ttc\": \"1275.60\", \"due\": \"2026-09-15\"}, {\"inv\": \"F-2026-102\", \"client\": \"Pharmacie du Port\", \"ttc\": \"612.00\", \"due\": \"2026-11-10\"}]"; json load "rx_pen" "[]"; json load "rx_tot" "[]"; #2. Days late, exact interest = amount x rate x days / 365, plus the 40 EUR recovery indemnity; for (-> "[i]" 0) (< [i] (json count "rx_ar" /)) (++ "[i]") { -> "[due]" (json select "rx_ar" (concat "/[" [i] "]/due")); if (date is_after [today] [due]) { -> "[days]" (date nb_day [due] [today]); -> "[int]" (bigdec round (bigdec div (bd* (bd* (json select "rx_ar" (concat "/[" [i] "]/ttc")) [rate]) [days]) 36500 20) 2); -> "[claim]" (bd+ [int] 40); json iarray "rx_tot" "/" [claim] NUM; json iarray "rx_pen" "/" (concat "{\"invoice\": \"" (json select "rx_ar" (concat "/[" [i] "]/inv")) "\", \"days_late\": " [days] ", \"interest\": " [int] ", \"claim\": " [claim] "}") OBJ; }; }; #3. Total to put on the reminder letters; -> "[out]" (concat "{\"legal_rate_pct\": " [rate] ", \"penalties\": " (string regexp_replace (json doc "rx_pen") "\s" "") ", \"total_claim\": " (bigdec sum (json doc "rx_tot")) "}"); json unload "rx_ar"; json unload "rx_pen"; json unload "rx_tot"; [out]
{"legal_rate_pct": 12.15, "penalties": [{"interest":150.45,"days_late":93,"claim":190.45,"invoice":"F-2026-071"},{"interest":19.96,"days_late":47,"claim":59.96,"invoice":"F-2026-088"}], "total_claim": 250.41}- The rate changes each half-year: read it from a parameter instead of the code
- Write the reminder letters from the penalties array with
mswordx replace
Bank reconciliation of account 512
At month end the accountant matches the 512 bank account entries with the bank statement: by reference found in the bank label, then by exact amount. The script lists the matched entries, the entries in the books only (cheque not cashed), the amount gaps (typing error) and the bank lines without a reference (fees), and checks that the balances differ by these items only.
#1. October entries of account 512 and the bank statement lines; json load "rx_gl" "[{\"ref\": \"VIR-4471\", \"amt\": \"-1250.00\"}, {\"ref\": \"CHQ-0918\", \"amt\": \"-380.40\"}, {\"ref\": \"CLI-2210\", \"amt\": \"2045.15\"}, {\"ref\": \"CB-7781\", \"amt\": \"-64.90\"}, {\"ref\": \"CLI-2214\", \"amt\": \"918.00\"}]"; json load "rx_bk" "[{\"label\": \"VIR SEPA VIR-4471 MARTIN\", \"amt\": \"-1250.00\"}, {\"label\": \"REM CLI-2210 HOARAU\", \"amt\": \"2045.15\"}, {\"label\": \"CB CB-7781 STATION\", \"amt\": \"-64.90\"}, {\"label\": \"FRAIS TENUE COMPTE\", \"amt\": \"-18.50\"}, {\"label\": \"REM CLI-2214 PAYET\", \"amt\": \"981.00\"}]"; json load "rx_rec" "{\"matched\": [], \"in_books_only\": [], \"amount_gap\": [], \"bank_only\": [], \"gl\": [], \"bank\": []}"; #2. Match each ledger entry on its reference in the bank label, then compare the exact amounts; for (-> "[i]" 0) (< [i] (json count "rx_gl" /)) (++ "[i]") { -> "[ref]" (json select "rx_gl" (concat "/[" [i] "]/ref")); -> "[amt]" (json select "rx_gl" (concat "/[" [i] "]/amt")); json iarray "rx_rec" "/gl" [amt] NUM; -> "[found]" -1; for (-> "[j]" 0) (< [j] (json count "rx_bk" /)) (++ "[j]") { if (string contains (json select "rx_bk" (concat "/[" [j] "]/label")) [ref]) {-> "[found]" [j];}; }; if (== [found] -1) {json iarray "rx_rec" "/in_books_only" [ref] STR;} { -> "[bamt]" (json select "rx_bk" (concat "/[" [found] "]/amt")); if (== (bigdec compare [amt] [bamt]) 0) {json iarray "rx_rec" "/matched" [ref] STR;} {json iarray "rx_rec" "/amount_gap" (concat [ref] " books " [amt] " bank " [bamt] " gap " (bigdec abs (bd- [bamt] [amt]))) STR;}; }; }; #3. Bank lines without reference in the books: fees, direct debits; for (-> "[j]" 0) (< [j] (json count "rx_bk" /)) (++ "[j]") { json iarray "rx_rec" "/bank" (json select "rx_bk" (concat "/[" [j] "]/amt")) NUM; if (not (string regexp (json select "rx_bk" (concat "/[" [j] "]/label")) ".*(VIR|CHQ|CLI|CB)-[0-9]{4}.*")) {json iarray "rx_rec" "/bank_only" (json select "rx_bk" (concat "/[" [j] "]/label")) STR;}; }; #4. Balances must differ only by the unreconciled items; -> "[gl_bal]" (bigdec sum (json select "rx_rec" "/gl")); -> "[bk_bal]" (bigdec sum (json select "rx_rec" "/bank")); json dobject "rx_rec" "/" "gl"; json dobject "rx_rec" "/" "bank"; -> "[out]" (concat "{\"books\": " [gl_bal] ", \"bank\": " [bk_bal] ", \"difference\": " (bd- [bk_bal] [gl_bal]) ", \"detail\": " (string regexp_replace (json doc "rx_rec") "\n *" "") "}"); json unload "rx_gl"; json unload "rx_bk"; json unload "rx_rec"; [out]
{"books": 1267.85, "bank": 1692.75, "difference": 424.90, "detail": {"in_books_only": ["CHQ-0918"],"amount_gap": ["CLI-2214 books 918.00 bank 981.00 gap 63.00"],"bank_only": ["FRAIS TENUE COMPTE"],"matched": ["VIR-4471","CLI-2210","CB-7781"]}}- Difference 424.90 = cheque 380.40 + typing gap 63.00 - fees 18.50: the reconciliation is complete
- Book the fees and the correction, then run it again: the difference must be 0
Benford test of expense claims
The auditor checks the first digits of 30 expense claims against the Benford law (log10 of 1 + 1/d). The script counts the first digits, compares them with the expected counts in a chi-square test and counts the claims just below the 5000 EUR approval limit, a classic sign of split or tuned claims.
#1. Expense claims of the year to audit, the approval limit without a second signature is 5000 EUR; json load "rx_exp" "[1240.50, 186.20, 4870.00, 312.75, 1980.00, 145.90, 4950.00, 2310.40, 118.60, 1460.00, 4795.00, 265.30, 1725.80, 3890.00, 132.40, 4920.00, 1090.00, 618.75, 4880.00, 2745.10, 174.00, 1312.60, 4990.00, 806.40, 1155.20, 4860.00, 3420.00, 97.80, 1570.30, 4935.00]"; json load "rx_dg" "[]"; -> "[digits]" ""; -> "[near]" 0; json parse_array "rx_exp" "/" "[x]" { -> "[digits]" (concat [digits] (string left [x] 1)); json iarray "rx_dg" "/" (string left [x] 1) INT; if (and (>= [x] 4500) (< [x] 5000)) {-> "[near]" (+ [near] 1);}; }; #2. Observed count of each first digit against the Benford law log10 of 1 + 1/d; json load "rx_obs" "[]"; json load "rx_ben" "[]"; for (-> "[d]" 1) (<= [d] 9) (++ "[d]") { json iarray "rx_obs" "/" (string regexp_count [digits] [d]) INT; json iarray "rx_ben" "/" (math round (* (math log10 (+ 1 (/ 1 [d]))) 30) 1) NUM; }; #3. Goodness of fit test, the most frequent first digit and the claims just below the limit; -> "[out]" (concat "{\"n\": " (json count "rx_exp" "/") ", \"observed_1_to_9\": " (string regexp_replace (json doc "rx_obs") "\s" "") ", \"benford_expected\": " (string regexp_replace (json doc "rx_ben") "\s" "") ", \"mode_digit\": " (stat mode (json doc "rx_dg")) ", \"just_below_5000\": " [near] ", \"test\": " (stat chi2_test (json doc "rx_obs") (json doc "rx_ben")) "}"); json unload "rx_exp"; json unload "rx_dg"; json unload "rx_obs"; json unload "rx_ben"; [out]
{"n": 30, "observed_1_to_9": [13,3,3,8,0,1,0,1,1], "benford_expected": [9.0,5.3,3.7,2.9,2.4,2.0,1.7,1.5,1.4], "mode_digit": [1], "just_below_5000": 8, "test": {"chi2": 16.702047178231297, "df": 8, "p_value": 0.03336558198265083}}- p = 0.033: the first digits do not follow Benford, mostly because of eight claims of 4795 to 4990 EUR just below the limit
- Use
stat frequencyon the first two digits for a finer test on a large ledger
Gaps and duplicates in the invoice numbering
Invoices must be numbered in one continuous sequence without gaps. The script extracts the number of each invoice of the till export, sorts them, finds the duplicated number with the mode and lists the missing numbers between two consecutive invoices.
#1. Sales invoice numbers exported from the till for October, French law requires a continuous sequence; -> "[list]" "FA2026-0412,FA2026-0415,FA2026-0413,FA2026-0418,FA2026-0414,FA2026-0416,FA2026-0420,FA2026-0416,FA2026-0421,FA2026-0419"; json load "rx_num" "[]"; json load "rx_raw" (string split [list] "," -1); json parse_array "rx_raw" "/" "[inv]" { json iarray "rx_num" "/" (string regexp_replace [inv] "^FA2026-0*" "") INT; }; #2. Sort the numbers, duplicates are the most frequent values; -> "[sorted]" (math order_int (json doc "rx_num")); -> "[dup]" (stat mode [sorted]); #3. Walk the sorted sequence and list the missing numbers; json load "rx_s" [sorted]; -> "[gaps]" ""; for (-> "[i]" 1) (< [i] (json count "rx_s" "/")) (++ "[i]") { -> "[prev]" (json select "rx_s" (concat "/[" (- [i] 1) "]")); for (-> "[k]" (+ [prev] 1)) (< [k] (json select "rx_s" (concat "/[" [i] "]"))) (++ "[k]") { -> "[gaps]" (concat [gaps] "FA2026-" (string lpad [k] "0" 4) " "); }; }; json unload "rx_num"; json unload "rx_raw"; json unload "rx_s"; concat "sorted: " [sorted] " | duplicated: FA2026-" (string lpad (string regexp_replace [dup] "[^0-9]" "") "0" 4) " | missing: " (string trim [gaps])
sorted: [412,413,414,415,416,416,418,419,420,421] | duplicated: FA2026-0416 | missing: FA2026-0417
- A missing number must be explained (cancelled invoice kept in the file), a duplicate is a till error to report
math order_longdoes the same for numbers above 2^31, such as a year and sequence joined: 20260000412
Aged receivables and doubtful debt allowance
At the closing date the accountant splits the open customer invoices into ageing buckets (not due, 1-30, 31-60, 61-90, over 90 days), computes the allowance for doubtful debts at 0, 10, 25 and 50 percent, the average days late and the age of the oldest invoice in weeks.
#1. Open customer invoices at the closing date 2026-10-31; -> "[close]" "2026-10-31"; json load "rx_ar" "[{\"client\": \"Batiman\", \"due\": \"2026-10-15\", \"amt\": \"3200.00\"}, {\"client\": \"Run Distribution\", \"due\": \"2026-11-20\", \"amt\": \"1840.50\"}, {\"client\": \"Pharmacie du Port\", \"due\": \"2026-08-31\", \"amt\": \"612.00\"}, {\"client\": \"Hotel Lagon\", \"due\": \"2026-09-10\", \"amt\": \"5420.00\"}, {\"client\": \"Garage Hoarau\", \"due\": \"2026-05-31\", \"amt\": \"980.00\"}, {\"client\": \"Cafe Bourbon\", \"due\": \"2026-10-02\", \"amt\": \"415.80\"}]"; json load "rx_age" "{\"not_due\": 0, \"d1_30\": 0, \"d31_60\": 0, \"d61_90\": 0, \"over_90\": 0}"; json load "rx_days" "[]"; -> "[prov]" 0; #2. Days past due, bucket and doubtful debt allowance rate of each invoice; json parse_array "rx_ar" "/" "[inv]" { json load "rx_i" [inv]; -> "[amt]" (json select "rx_i" "/amt"); -> "[late]" (date nb_day (json select "rx_i" "/due") [close]); if (> [late] 0) {json iarray "rx_days" "/" [late] INT;}; if (<= [late] 0) {-> "[b]" "not_due"; -> "[r]" 0;} {if (<= [late] 30) {-> "[b]" "d1_30"; -> "[r]" 0;} {if (<= [late] 60) {-> "[b]" "d31_60"; -> "[r]" 10;} {if (<= [late] 90) {-> "[b]" "d61_90"; -> "[r]" 25;} {-> "[b]" "over_90"; -> "[r]" 50;};};};}; json uobject "rx_age" "/" [b] (bd+ (json select "rx_age" (concat "/" [b])) [amt]) NUM; -> "[prov]" (bd+ [prov] (bigdec round (bigdec div (bd* [amt] [r]) 100 10) 2)); }; #3. Ageing balance, average days late and weeks of the oldest invoice; -> "[out]" (concat "{\"ageing\": " (string regexp_replace (json doc "rx_age") "\s" "") ", \"avg_days_late\": " (math round (math avg (json doc "rx_days")) 1) ", \"oldest_weeks\": " (math trunc (/ (stat max (json doc "rx_days")) 7)) ", \"allowance\": " [prov] "}"); json unload "rx_ar"; json unload "rx_i"; json unload "rx_age"; json unload "rx_days"; [out]
{"ageing": {"d61_90":612.0,"over_90":980.0,"d1_30":3615.8,"d31_60":5420.0,"not_due":1840.5}, "avg_days_late": 62, "oldest_weeks": 21, "allowance": 1185.00}- Book the allowance in 6817 / 491 and reverse last year's one
- Weight the days late by the amounts with
stat weighted_mean: a 5420 EUR invoice matters more than a 415 EUR one
Reciprocal allocation of service department costs
IT and Maintenance serve each other and two production shops. The reciprocal method solves the system IT = 120000 + 10 pct of Maintenance, Maintenance = 80000 + 20 pct of IT, then allocates the full costs to Assembly and Painting. The LU factorisation shows the system is well posed.
#1. Service departments IT and Maintenance serve each other and two production shops; #IT = 120000 + 10 pct of Maintenance, Maintenance = 80000 + 20 pct of IT; -> "[A]" "[[1, -0.10], [-0.20, 1]]"; -> "[direct]" "[120000, 80000]"; #2. LU factorisation of the reciprocal system, its determinant is the product of the U diagonal; json load "rx_lu" (matrix lu [A]); -> "[det]" (* (json select "rx_lu" "/U/[0]/[0]") (json select "rx_lu" "/U/[1]/[1]")); #3. Total costs to allocate, then the shares of Assembly and Painting: IT 50 and 30 pct, Maintenance 40 and 50 pct; -> "[total]" (matrix solve [A] [direct]); -> "[alloc]" (matrix mul "[[0.50, 0.40], [0.30, 0.50]]" [total]); json load "rx_al" [alloc]; -> "[out]" (concat "{\"det\": " (math round [det] 4) ", \"total_it_maint\": " [total] ", \"assembly\": " (math round (json select "rx_al" "/[0]") 2) ", \"painting\": " (math round (json select "rx_al" "/[1]") 2) ", \"check_sum\": " (math round (+ (json select "rx_al" "/[0]") (json select "rx_al" "/[1]")) 2) "}"); json unload "rx_lu"; json unload "rx_al"; [out]
{"det": 0.98, "total_it_maint": [130612.24489795919,106122.44897959183], "assembly": 107755.1, "painting": 92244.9, "check_sum": 200000}- The direct method ignores the services between IT and Maintenance: compare its result with this one
- With n service departments the same code works with an n x n matrix
Cash rounding of tickets to 5 cents
A shop that does not give 1 and 2 cent coins rounds each cash total to the nearest 0.05, credit notes included. The script keeps the sign of each ticket, rounds its absolute value, shows the cents and books the net rounding difference to the gain (758) or loss (658) account.
#1. Cash sales of a shop that rounds the total to 5 cents, a credit note is negative; json load "rx_t" "[12.42, 7.98, 23.47, -4.12, 18.03, 9.97, 31.26, -15.48]"; -> "[gain]" 0; -> "[lines]" ""; json parse_array "rx_t" "/" "[x]" { #2. Cents part, then nearest 0.05 on the absolute value, the sign of the ticket is kept; -> "[cents]" (math round (* (math frac_part [x]) 100) 0); -> "[paid]" (* (math sign [x]) (/ (math rint (* (math abs [x]) 20)) 20)); -> "[diff]" (math round (- [paid] [x]) 2); -> "[gain]" (+ [gain] [diff]); -> "[lines]" (concat [lines] [x] " pays " (math round [paid] 2) " cents " [cents] ", "); }; json unload "rx_t"; #3. The rounding differences go to 758 when positive, 658 when negative; -> "[g]" (math round [gain] 2); concat [lines] "net rounding " [g] " EUR to account " (if (>= (math signum [g]) 0) {758;} {658;})
12.42 pays 12.4 cents 42, 7.98 pays 8 cents 98, 23.47 pays 23.45 cents 47, -4.12 pays -4.1 cents -12, 18.03 pays 18.05 cents 3, 9.97 pays 9.95 cents 97, 31.26 pays 31.25 cents 26, -15.48 pays -15.5 cents -48, net rounding -0.03 EUR to account 658
math rintrounds a tie such as 2.5 to the even integer 2: for a ticket ending in 0.025 the shop must choose its rule- Card payments are not rounded: apply this only to the cash tickets
