id: retail-upi-cash-day-close
namespace: company.team
description: |
Close the day at a retail store: match every UPI bill against the payment
settlement report, catch payments that never arrived, reused UPI references,
short payments and money received without a bill, and reconcile each
counter's cash drawer against the cash sales.
inputs:
- id: pos_bills
type: FILE
required: false
description: CSV export of the day's bills from the POS with the columns
bill_id, terminal, bill_time, payment_mode (CASH, UPI or CARD), amount
(negative for refunds) and upi_ref (the 12-digit UPI reference / RRN,
blank if the cashier did not record it). Leave empty to use the built-in
sample.
- id: upi_settlement
type: FILE
required: false
description: CSV of the day's UPI transactions from your payment provider or
bank with the columns rrn, txn_time, amount, status (SUCCESS, FAILED or
PENDING) and terminal. Leave empty to use the built-in sample.
- id: cash_count
type: FILE
required: false
description: CSV of the closing cash count per counter with the columns
terminal, opening_float and counted_cash. Leave empty to use the built-in
sample.
- id: cash_tolerance
type: FLOAT
defaults: 50
description: Difference (in rupees) between counted and expected cash that is
still accepted as a match, to absorb small change.
- id: match_window_minutes
type: INT
defaults: 10
description: When a UPI bill has no reference, a payment of the same amount on
the same counter within this many minutes of the bill is suggested as its
match.
- id: notify_slack
type: BOOL
defaults: false
description: Post the findings to Slack when the day does not close cleanly.
Requires the SLACK_WEBHOOK_URL secret.
triggers:
- id: nightly_close
type: io.kestra.plugin.core.trigger.Schedule
description: Run the close every night at 23:30 IST, after the store shuts and
the settlement report is available. Shipped disabled; enable it once the
flow reads your real POS, settlement and cash count exports instead of the
samples.
cron: "30 23 * * *"
timezone: Asia/Kolkata
disabled: true
tasks:
- id: sample_pos_bills
type: io.kestra.plugin.core.storage.Write
description: One day of bills from two counters, with one example of every
problem the close catches. Only used when pos_bills is empty.
extension: .csv
content: |
bill_id,terminal,bill_time,payment_mode,amount,upi_ref
B001,T1,2026-10-05 09:12:00,CASH,450.00,
B002,T1,2026-10-05 09:30:00,UPI,1200.00,627811234501
B003,T1,2026-10-05 10:05:00,UPI,349.00,627811234502
B004,T1,2026-10-05 10:40:00,CARD,2100.00,
B005,T1,2026-10-05 11:15:00,UPI,899.00,627811234503
B006,T1,2026-10-05 12:02:00,CASH,760.00,
B007,T1,2026-10-05 12:30:00,UPI,560.00,627811234504
B008,T1,2026-10-05 13:10:00,CASH,-120.00,
B009,T2,2026-10-05 09:45:00,UPI,1500.00,627811234505
B010,T2,2026-10-05 10:20:00,UPI,1500.00,627811234505
B011,T2,2026-10-05 11:05:00,CASH,980.00,
B012,T2,2026-10-05 11:50:00,UPI,275.00,
B013,T2,2026-10-05 12:40:00,UPI,640.00,627811234508
B014,T2,2026-10-05 13:30:00,CASH,1340.00,
B015,T2,2026-10-05 14:05:00,UPI,410.00, 6278 1123 4509
- id: sample_upi_settlement
type: io.kestra.plugin.core.storage.Write
description: The matching UPI settlement report from the payment provider. Only
used when upi_settlement is empty.
extension: .csv
content: |
rrn,txn_time,amount,status,terminal
627811234501,2026-10-05 09:30:41,1200.00,SUCCESS,T1
627811234502,2026-10-05 10:05:12,349.00,SUCCESS,T1
627811234503,2026-10-05 11:15:30,899.00,FAILED,T1
627811234504,2026-10-05 12:30:55,506.00,SUCCESS,T1
627811234506,2026-10-05 13:45:10,820.00,SUCCESS,T1
627811234505,2026-10-05 09:45:20,1500.00,SUCCESS,T2
627811234507,2026-10-05 11:52:03,275.00,SUCCESS,T2
627811234509,2026-10-05 14:05:40,410.00,SUCCESS,T2
- id: sample_cash_count
type: io.kestra.plugin.core.storage.Write
description: The closing cash count per counter. Only used when cash_count is empty.
extension: .csv
content: |
terminal,opening_float,counted_cash
T1,2000.00,2890.00
T2,2000.00,4330.00
- id: reconcile
type: io.kestra.plugin.jdbc.duckdb.Queries
description: Load the three files and run every check in DuckDB, without calling
any external service. The exceptions and the per-counter summary are
exported to CSV and the last query returns the totals.
inputFiles:
pos_bills.csv: "{{ inputs.pos_bills ?? outputs.sample_pos_bills.uri }}"
upi_settlement.csv: "{{ inputs.upi_settlement ?? outputs.sample_upi_settlement.uri }}"
cash_count.csv: "{{ inputs.cash_count ?? outputs.sample_cash_count.uri }}"
outputFiles:
- exceptions.csv
- counter_summary.csv
fetchType: FETCH_ONE
sql: |
CREATE TABLE bills AS
SELECT
trim(bill_id) AS bill_id,
trim(terminal) AS terminal,
CAST(bill_time AS TIMESTAMP) AS bill_time,
upper(trim(payment_mode)) AS payment_mode,
CAST(amount AS DECIMAL(12, 2)) AS amount,
nullif(regexp_replace(coalesce(upi_ref, ''), '\s', '', 'g'), '') AS upi_ref
FROM read_csv('{{ workingDir }}/pos_bills.csv', header = true, all_varchar = true);
CREATE TABLE upi AS
SELECT
regexp_replace(trim(rrn), '\s', '', 'g') AS rrn,
CAST(txn_time AS TIMESTAMP) AS txn_time,
CAST(amount AS DECIMAL(12, 2)) AS amount,
upper(trim(status)) AS status,
trim(terminal) AS terminal
FROM read_csv('{{ workingDir }}/upi_settlement.csv', header = true, all_varchar = true);
CREATE TABLE cash AS
SELECT
trim(terminal) AS terminal,
CAST(opening_float AS DECIMAL(12, 2)) AS opening_float,
CAST(counted_cash AS DECIMAL(12, 2)) AS counted_cash
FROM read_csv('{{ workingDir }}/cash_count.csv', header = true, all_varchar = true);
-- UPI bills, with how many bills share the same reference.
CREATE TABLE upi_bills AS
SELECT
*,
count(*) OVER (PARTITION BY upi_ref) AS ref_uses,
string_agg(bill_id, ', ') OVER (PARTITION BY upi_ref) AS ref_bills
FROM bills
WHERE payment_mode = 'UPI';
CREATE TABLE ref_match AS
SELECT b.bill_id, u.rrn, u.amount AS received_amount, u.status
FROM upi_bills b
JOIN upi u ON u.rrn = b.upi_ref;
-- Bills without a reference: look for successful payments of the same amount
-- on the same counter, close in time, that no other bill claims.
CREATE TABLE suggested AS
SELECT b.bill_id, min(u.rrn) AS rrn, count(*) AS candidates
FROM upi_bills b
JOIN upi u
ON b.upi_ref IS NULL
AND u.status = 'SUCCESS'
AND u.terminal = b.terminal
AND u.amount = b.amount
AND abs(epoch(u.txn_time) - epoch(b.bill_time)) <= {{ inputs.match_window_minutes }} * 60
AND u.rrn NOT IN (SELECT upi_ref FROM upi_bills WHERE upi_ref IS NOT NULL)
GROUP BY b.bill_id;
CREATE TABLE counters AS
SELECT terminal FROM bills UNION SELECT terminal FROM cash UNION SELECT terminal FROM upi;
CREATE TABLE counter_summary AS
SELECT
t.terminal,
count(b.bill_id) AS bills,
coalesce(sum(b.amount) FILTER (WHERE b.payment_mode = 'CASH'), 0) AS cash_sales,
coalesce(sum(b.amount) FILTER (WHERE b.payment_mode = 'UPI'), 0) AS upi_billed,
coalesce(sum(b.amount) FILTER (WHERE b.payment_mode = 'CARD'), 0) AS card_sales,
(SELECT coalesce(sum(u.amount), 0) FROM upi u WHERE u.terminal = t.terminal AND u.status = 'SUCCESS') AS upi_received,
any_value(c.opening_float) AS opening_float,
any_value(c.opening_float) + coalesce(sum(b.amount) FILTER (WHERE b.payment_mode = 'CASH'), 0) AS expected_cash,
any_value(c.counted_cash) AS counted_cash
FROM counters t
LEFT JOIN cash c ON c.terminal = t.terminal
LEFT JOIN bills b ON b.terminal = t.terminal
GROUP BY t.terminal
ORDER BY t.terminal;
CREATE TABLE exceptions AS
SELECT * FROM (
SELECT
'ERROR' AS severity, 'DUPLICATE_UPI_REF' AS check_name, terminal, bill_id, upi_ref AS upi_reference,
amount AS billed, CAST(NULL AS DECIMAL(12, 2)) AS received,
'UPI reference ' || upi_ref || ' is on bills ' || ref_bills || ': one payment was counted for more than one bill' AS detail
FROM upi_bills
WHERE upi_ref IS NOT NULL AND ref_uses > 1
UNION ALL
SELECT
'ERROR', 'UPI_NOT_RECEIVED', b.terminal, b.bill_id, b.upi_ref, b.amount, NULL,
CASE
WHEN m.status IS NULL THEN 'UPI reference ' || b.upi_ref || ' is not in the settlement report: the money never arrived (fake or edited payment screen?)'
ELSE 'UPI payment ' || b.upi_ref || ' is ' || m.status || ' in the settlement report, but the bill was closed as paid'
END
FROM upi_bills b
LEFT JOIN ref_match m ON m.bill_id = b.bill_id
WHERE b.upi_ref IS NOT NULL AND coalesce(m.status, '') <> 'SUCCESS'
UNION ALL
SELECT
'ERROR', 'UPI_AMOUNT_MISMATCH', b.terminal, b.bill_id, b.upi_ref, b.amount, m.received_amount,
'Bill is Rs ' || b.amount || ' but Rs ' || m.received_amount || ' was received (' || CASE WHEN m.received_amount < b.amount THEN 'short' ELSE 'over' END || ' by Rs ' || abs(m.received_amount - b.amount) || ')'
FROM upi_bills b
JOIN ref_match m ON m.bill_id = b.bill_id
WHERE m.status = 'SUCCESS' AND m.received_amount <> b.amount
UNION ALL
SELECT
CASE WHEN s.candidates = 1 THEN 'WARNING' ELSE 'ERROR' END,
CASE WHEN s.candidates >= 1 THEN 'UPI_REF_MISSING' ELSE 'UPI_NOT_RECEIVED' END,
b.terminal, b.bill_id,
CASE WHEN s.candidates = 1 THEN s.rrn END,
b.amount,
CASE WHEN s.candidates = 1 THEN b.amount END,
CASE
WHEN s.candidates = 1 THEN 'No UPI reference on the bill; matched to payment ' || s.rrn || ' by amount and time. Ask cashiers to record the reference'
WHEN s.candidates > 1 THEN 'No UPI reference on the bill and ' || s.candidates || ' payments of the same amount fit; check it by hand'
ELSE 'No UPI reference on the bill and no payment of Rs ' || b.amount || ' on this counter within {{ inputs.match_window_minutes }} minutes'
END
FROM upi_bills b
LEFT JOIN suggested s ON s.bill_id = b.bill_id
WHERE b.upi_ref IS NULL
UNION ALL
SELECT
'WARNING', 'UPI_WITHOUT_BILL', u.terminal, NULL, u.rrn, NULL, u.amount,
'Rs ' || u.amount || ' received at ' || strftime(u.txn_time, '%H:%M') || ' with no bill: sale not billed, or bill cancelled after payment'
FROM upi u
WHERE u.status = 'SUCCESS'
AND u.rrn NOT IN (SELECT upi_ref FROM upi_bills WHERE upi_ref IS NOT NULL)
AND u.rrn NOT IN (SELECT rrn FROM suggested WHERE candidates = 1)
UNION ALL
SELECT
CASE WHEN counted_cash IS NULL OR counted_cash < expected_cash THEN 'ERROR' ELSE 'WARNING' END,
CASE WHEN counted_cash IS NULL THEN 'CASH_NOT_COUNTED' WHEN counted_cash < expected_cash THEN 'CASH_SHORT' ELSE 'CASH_OVER' END,
terminal, NULL, NULL, expected_cash, counted_cash,
CASE
WHEN counted_cash IS NULL THEN 'No closing cash count for this counter'
ELSE 'Drawer has Rs ' || counted_cash || ', expected Rs ' || expected_cash || ' (opening float Rs ' || opening_float || ' + cash sales Rs ' || cash_sales || '): ' || CASE WHEN counted_cash < expected_cash THEN 'short' ELSE 'over' END || ' by Rs ' || abs(counted_cash - expected_cash)
END
FROM counter_summary
WHERE counted_cash IS NULL OR abs(counted_cash - expected_cash) > {{ inputs.cash_tolerance }}
)
ORDER BY CASE severity WHEN 'ERROR' THEN 0 ELSE 1 END, terminal, bill_id NULLS LAST;
COPY exceptions TO '{{ outputFiles["exceptions.csv"] }}' (HEADER, DELIMITER ',');
COPY counter_summary TO '{{ outputFiles["counter_summary.csv"] }}' (HEADER, DELIMITER ',');
SELECT
(SELECT count(*) FROM bills) AS total_bills,
(SELECT sum(amount) FROM bills) AS total_sales,
(SELECT sum(upi_billed) FROM counter_summary) AS upi_billed,
(SELECT sum(upi_received) FROM counter_summary) AS upi_received,
(SELECT sum(counted_cash - expected_cash) FROM counter_summary) AS cash_difference,
count(*) FILTER (WHERE severity = 'ERROR') AS error_count,
count(*) FILTER (WHERE severity = 'WARNING') AS warning_count,
coalesce(string_agg(terminal || ' ' || coalesce(bill_id, '-') || ' ' || check_name || ': ' || detail, chr(10)) FILTER (WHERE severity = 'ERROR'), '') AS error_details,
coalesce(string_agg(terminal || ' ' || coalesce(bill_id, '-') || ' ' || check_name || ': ' || detail, chr(10)) FILTER (WHERE severity = 'WARNING'), '') AS warning_details
FROM exceptions;
- id: route_findings
type: io.kestra.plugin.core.flow.If
description: Branch on the result. Errors mean money is missing or counted
twice, so they are logged and optionally sent to Slack; a clean close is
logged so the execution history doubles as the store's daily close
register.
condition: "{{ outputs.reconcile.outputs[0].row.error_count > 0 }}"
then:
- id: log_errors
type: io.kestra.plugin.core.log.Log
level: WARN
description: Write every exception and the reason to the execution logs.
message: |
Day close: {{ outputs.reconcile.outputs[0].row.error_count }} error(s), {{ outputs.reconcile.outputs[0].row.warning_count }} warning(s) across {{ outputs.reconcile.outputs[0].row.total_bills }} bills.
UPI billed Rs {{ outputs.reconcile.outputs[0].row.upi_billed | numberFormat('#,##0.00') }}, received Rs {{ outputs.reconcile.outputs[0].row.upi_received | numberFormat('#,##0.00') }}. Cash difference Rs {{ outputs.reconcile.outputs[0].row.cash_difference | numberFormat('#,##0.00') }}.
Errors:
{{ outputs.reconcile.outputs[0].row.error_details }}
Warnings:
{{ outputs.reconcile.outputs[0].row.warning_details }}
- id: slack_enabled
type: io.kestra.plugin.core.flow.If
description: Only call Slack when the notify_slack input is true, so the flow
runs out of the box without any secret.
condition: "{{ inputs.notify_slack }}"
then:
- id: alert_store_manager
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Tell the store manager what to check before the next shift opens.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
:warning: Day close found {{ outputs.reconcile.outputs[0].row.error_count }} error(s) and {{ outputs.reconcile.outputs[0].row.warning_count }} warning(s).
UPI billed Rs {{ outputs.reconcile.outputs[0].row.upi_billed | numberFormat('#,##0.00') }}, received Rs {{ outputs.reconcile.outputs[0].row.upi_received | numberFormat('#,##0.00') }}. Cash difference Rs {{ outputs.reconcile.outputs[0].row.cash_difference | numberFormat('#,##0.00') }}.
{{ outputs.reconcile.outputs[0].row.error_details }}
Full report: execution {{ execution.id }} in flow {{ flow.namespace }}.{{ flow.id }}
else:
- id: log_clean
type: io.kestra.plugin.core.log.Log
description: Record a clean close.
message: "Day closed cleanly: {{ outputs.reconcile.outputs[0].row.total_bills }}
bills, UPI billed Rs {{ outputs.reconcile.outputs[0].row.upi_billed |
numberFormat('#,##0.00') }} and received Rs {{
outputs.reconcile.outputs[0].row.upi_received |
numberFormat('#,##0.00') }}, {{
outputs.reconcile.outputs[0].row.warning_count }} warning(s)."
errors:
- id: log_close_failure
type: io.kestra.plugin.core.log.Log
level: ERROR
description: Make a failed close loud. If a file is missing or malformed,
nothing was reconciled, and a silent failure looks like a clean day.
message: "Day close FAILED in {{ flow.namespace }}.{{ flow.id }} (execution {{
execution.id }}). The day was not closed. Check the columns of pos_bills,
upi_settlement and cash_count."
outputs:
- id: exceptions
type: FILE
description: Every exception with severity (ERROR or WARNING), check name,
counter, bill, UPI reference, billed and received amounts and the reason.
value: "{{ outputs.reconcile.outputFiles['exceptions.csv'] }}"
- id: counter_summary
type: FILE
description: One row per counter with bills, cash, UPI and card sales, UPI
received, opening float, expected and counted cash.
value: "{{ outputs.reconcile.outputFiles['counter_summary.csv'] }}"
- id: close_summary
type: JSON
description: 'Totals and details, e.g. {"total_bills": 15, "error_count": 6,
"warning_count": 2, ...}.'
value: "{{ outputs.reconcile.outputs[0].row | toJson }}"