File size: 7,585 Bytes
6f0c329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo


def run_date_sanity_checks(

    *,

    document,

    extracted_fields,

    platform_configs=None,

):
    """

    Runs Date Sanity Checks after date normalisation.



    Expected document fields:

        document.id

        document.tenant_id

        Optional: document.tenant_timezone



    Expected extracted_fields fields:

        invoice_date

        due_date

        due_date_origin

        payment_terms

        service_start_date

        service_end_date



    Returns:

        dict with advisory risk flags and audit-friendly result.

    """

    platform_configs = platform_configs or {}

    default_timezone = platform_configs.get(
        "validation.default_timezone",
        getattr(document, "tenant_timezone", "UTC") or "UTC",
    )

    max_invoice_age_days = int(
        platform_configs.get("validation.max_invoice_age_days", 365)
    )

    max_invoice_age_days = max(30, min(max_invoice_age_days, 1825))

    service_end_grace_days = int(
        platform_configs.get("validation.service_end_post_invoice_grace_days", 0)
    )

    def as_date(value):
        if value is None or value == "":
            return None

        if isinstance(value, datetime):
            return value.date()

        if isinstance(value, date):
            return value

        return datetime.strptime(str(value), "%Y-%m-%d").date()

    def as_int(value):
        if value is None or value == "":
            return None
        try:
            return int(value)
        except (ValueError, TypeError):
            return None

    def tenant_current_date():
        try:
            return datetime.now(ZoneInfo(default_timezone)).date()
        except Exception:
            return datetime.now(ZoneInfo("UTC")).date()

    def add_flag(code, *, weight, field, detail=None):
        risk_flags.append(
            {
                "code": code,
                "severity": "advisory",
                "weight": weight,
                "field": field,
                "detail": detail or {},
            }
        )

    risk_flags = []
    advisory_flags = []
    skipped_steps = []

    invoice_date = as_date(getattr(extracted_fields, "invoice_date", None))
    due_date = as_date(getattr(extracted_fields, "due_date", None))
    service_start_date = as_date(getattr(extracted_fields, "service_start_date", None))
    service_end_date = as_date(getattr(extracted_fields, "service_end_date", None))
    due_date_origin = getattr(extracted_fields, "due_date_origin", None)
    payment_terms = as_int(getattr(extracted_fields, "payment_terms", None))

    current_date = tenant_current_date()

    # 1. Payment terms must be non-negative.
    # Zero is allowed: due-on-receipt.
    if payment_terms is not None and payment_terms < 0:
        add_flag(
            "date_anomaly_payment_terms_negative",
            weight=0.20,
            field="payment_terms",
            detail={
                "payment_terms": payment_terms,
            },
        )

    # 2. Due date must be on or after invoice date.
    if invoice_date is not None and due_date is not None:
        if due_date < invoice_date:
            add_flag(
                "date_anomaly_due_before_invoice",
                weight=0.20,
                field="due_date",
                detail={
                    "invoice_date": str(invoice_date),
                    "due_date": str(due_date),
                },
            )

    if invoice_date is None:
        skipped_steps.extend([
            {"step": "date_anomaly_invoice_future_dated", "skip_reason": "no_invoice_date"},
            {"step": "date_anomaly_invoice_too_old", "skip_reason": "no_invoice_date"},
        ])

    # 3. Invoice must not be future-dated.
    if invoice_date is not None and invoice_date > current_date:
        add_flag(
            "date_anomaly_invoice_future_dated",
            weight=0.20,
            field="invoice_date",
            detail={
                "invoice_date": str(invoice_date),
                "current_date": str(current_date),
                "timezone": default_timezone,
            },
        )

    # 4. Invoice must not be too old.
    if invoice_date is not None:
        oldest_allowed_date = current_date - timedelta(days=max_invoice_age_days)

        if invoice_date < oldest_allowed_date:
            add_flag(
                "date_anomaly_invoice_too_old",
                weight=0.15,
                field="invoice_date",
                detail={
                    "invoice_date": str(invoice_date),
                    "oldest_allowed_date": str(oldest_allowed_date),
                    "max_invoice_age_days": max_invoice_age_days,
                },
            )

    # 5. Service period must not be reversed.
    if service_start_date is not None and service_end_date is not None:
        if service_end_date < service_start_date:
            add_flag(
                "date_anomaly_service_period_reversed",
                weight=0.15,
                field="service_end_date",
                detail={
                    "service_start_date": str(service_start_date),
                    "service_end_date": str(service_end_date),
                },
            )

    # 6. Service end must not be too far after invoice date.
    if service_end_date is not None:
        if invoice_date is None:
            skipped_steps.append(
                {"step": "date_anomaly_service_end_after_invoice", "skip_reason": "no_invoice_date"}
            )
        else:
            allowed_service_end = invoice_date + timedelta(days=service_end_grace_days)

            if service_end_date > allowed_service_end:
                add_flag(
                    "date_anomaly_service_end_after_invoice",
                    weight=0.15,
                    field="service_end_date",
                    detail={
                        "invoice_date": str(invoice_date),
                        "service_end_date": str(service_end_date),
                        "allowed_service_end": str(allowed_service_end),
                        "grace_days": service_end_grace_days,
                    },
                )

    # 7. Due date could not be derived.
    if due_date is None and due_date_origin == "unknown":
        add_flag(
            "due_date_not_derivable",
            weight=0.10,
            field="due_date",
            detail={
                "due_date_origin": due_date_origin,
            },
        )

    return {
        "risk_flags": risk_flags,
        "advisory_flags": advisory_flags,
        "skipped_steps": skipped_steps,
        "flag_codes": [flag["code"] for flag in risk_flags],
        "checked_fields": {
            "invoice_date": str(invoice_date) if invoice_date else None,
            "due_date": str(due_date) if due_date else None,
            "due_date_origin": due_date_origin,
            "payment_terms": payment_terms,
            "service_start_date": str(service_start_date) if service_start_date else None,
            "service_end_date": str(service_end_date) if service_end_date else None,
            "current_date": str(current_date),
            "timezone": default_timezone,
            "max_invoice_age_days": max_invoice_age_days,
            "service_end_post_invoice_grace_days": service_end_grace_days,
        },
    }