<!--- #10745 / #10840: Invoices / Payments month lock.
     A record's month is its Invoices.DateSent / Payments.PaymentDate (the dates the AR report uses).
     The prior calendar month locks automatically on the 8th business day of the next month (weekdays only; weekends are not counted).
     October 2026 locks on Wednesday 11/11, and that date is what is stored in lock_date.
     Invoice_Lock / Payment_Lock = 1.
     Locked records keep their amounts, dates, payer and delete flag; adjustments are made on a copy with every date cleared.
     Everything here is a no-op until migrations/10745_billing_payment_lock_date_all_agencies.cfm has added lock_date to the agency database. --->
<cfcomponent displayname="BillingLock" output="false">

	<cffunction name="lockThroughDate" access="public" returntype="date" output="false"
		hint="Last day of the latest locked month. A completed month locks on the 8th weekday after it ends.">
		<cfargument name="asOf" type="date" required="false" default="#Now()#" />
		<cfset var asOfDay = CreateDate(Year(arguments.asOf), Month(arguments.asOf), Day(arguments.asOf)) />
		<cfset var monthStart = CreateDate(Year(asOfDay), Month(asOfDay), 1) />
		<cfset var monthEnd = DateAdd("d", -1, monthStart) />
		<cfif DateCompare(asOfDay, addBusinessDays(monthEnd, 8), "d") LT 0>
			<cfset monthEnd = DateAdd("d", -1, CreateDate(Year(monthEnd), Month(monthEnd), 1)) />
		</cfif>
		<cfreturn monthEnd />
	</cffunction>

	<cffunction name="monthLockDate" access="public" returntype="date" output="false"
		hint="The lock_date for a record: the 8th weekday of the month after the record's month. October 2026 returns November 11.">
		<cfargument name="recordDate" type="date" required="true" />
		<cfset var monthStart = CreateDate(Year(arguments.recordDate), Month(arguments.recordDate), 1) />
		<cfset var monthEnd = DateAdd("d", -1, DateAdd("m", 1, monthStart)) />
		<cfreturn addBusinessDays(monthEnd, 8) />
	</cffunction>

	<cffunction name="addBusinessDays" access="private" returntype="date" output="false"
		hint="The date that is N weekdays after startDate. Saturday and Sunday are not counted, and startDate itself is not counted.">
		<cfargument name="startDate" type="date" required="true" />
		<cfargument name="businessDays" type="numeric" required="true" />
		<cfset var d = arguments.startDate />
		<cfset var added = 0 />
		<cfset var dow = 0 />
		<cfloop condition="added LT arguments.businessDays">
			<cfset d = DateAdd("d", 1, d) />
			<cfset dow = DayOfWeek(d) />
			<cfif dow NEQ 1 AND dow NEQ 7>
				<cfset added = added + 1 />
			</cfif>
		</cfloop>
		<cfreturn d />
	</cffunction>

	<cffunction name="isLive" access="public" returntype="boolean" output="false"
		hint="True once Invoices and Payments have lock_date in this agency database">
		<cfargument name="db" type="string" required="false" default="#Request.prefix_db_agency#" />
		<cfset var q = "" />
		<cfif StructKeyExists(Application, "billingLockLive") AND StructKeyExists(Application.billingLockLive, arguments.db)>
			<cfreturn true />
		</cfif>
		<cfquery name="q" datasource="#Application.DataSrc#">
			SELECT COUNT(*) AS n
			FROM information_schema.COLUMNS
			WHERE TABLE_SCHEMA = <cfqueryparam value="#arguments.db#" cfsqltype="cf_sql_varchar">
			AND TABLE_NAME IN ('Invoices', 'Payments')
			AND COLUMN_NAME = 'lock_date'
		</cfquery>
		<cfif Val(q.n) LT 2>
			<cfreturn false />
		</cfif>
		<cflock scope="application" type="exclusive" timeout="5">
			<cfif NOT StructKeyExists(Application, "billingLockLive")>
				<cfset Application.billingLockLive = {} />
			</cfif>
			<cfset Application.billingLockLive[arguments.db] = true />
		</cflock>
		<cfreturn true />
	</cffunction>

	<cffunction name="notLockedSql" access="public" returntype="string" output="false"
		hint="SQL fragment for WHERE clauses: the row has no month lock">
		<cfargument name="alias" type="string" required="false" default="" />
		<cfif NOT isLive()>
			<cfreturn "" />
		</cfif>
		<cfreturn " AND " & (Len(arguments.alias) ? arguments.alias & "." : "") & "lock_date IS NULL " />
	</cffunction>

	<cffunction name="statusUpdateSql" access="public" returntype="string" output="false"
		hint="Replaces 'IFNULL(Invoice_Lock,0) <> 1' on claim status updates: month-locked invoices still take status changes">
		<cfargument name="db" type="string" required="false" default="#Request.prefix_db_agency#" />
		<cfif NOT isLive(arguments.db)>
			<cfreturn " AND (IFNULL(Invoice_Lock,0) <> 1) " />
		</cfif>
		<cfreturn " AND (IFNULL(Invoice_Lock,0) <> 1 OR lock_date IS NOT NULL) " />
	</cffunction>

	<cffunction name="invoiceLock" access="public" returntype="struct" output="false"
		hint="{locked, lockDate, recordDate (DateSent), adjustedFrom}">
		<cfargument name="invoiceId" required="true" />
		<cfreturn rowLock("Invoices", "Invoice_ID", "DateSent", arguments.invoiceId) />
	</cffunction>

	<cffunction name="paymentLock" access="public" returntype="struct" output="false"
		hint="{locked, lockDate, recordDate (PaymentDate), adjustedFrom}">
		<cfargument name="paymentId" required="true" />
		<cfreturn rowLock("Payments", "Payment_ID", "PaymentDate", arguments.paymentId) />
	</cffunction>

	<cffunction name="rowLock" access="private" returntype="struct" output="false">
		<cfargument name="tableName" type="string" required="true" />
		<cfargument name="keyCol" type="string" required="true" />
		<cfargument name="dateCol" type="string" required="true" />
		<cfargument name="id" required="true" />
		<cfset var result = { locked = false, lockDate = "", recordDate = "", adjustedFrom = 0 } />
		<cfset var q = "" />
		<cfif NOT isLive() OR NOT IsNumeric(arguments.id) OR Val(arguments.id) LTE 0>
			<cfreturn result />
		</cfif>
		<cfquery name="q" datasource="#Application.DataSrc#">
			SELECT lock_date, #arguments.dateCol# AS record_date, IFNULL(adjusted_from_id, 0) AS adjusted_from_id
			FROM #Request.prefix_db_agency#.#arguments.tableName#
			WHERE #arguments.keyCol# = <cfqueryparam value="#Val(arguments.id)#" cfsqltype="cf_sql_integer">
			LIMIT 1
		</cfquery>
		<cfif q.recordcount>
			<cfset result.locked = IsDate(q.lock_date) />
			<cfset result.lockDate = q.lock_date />
			<cfset result.recordDate = q.record_date />
			<cfset result.adjustedFrom = Val(q.adjusted_from_id) />
		</cfif>
		<cfreturn result />
	</cffunction>

	<cffunction name="dateChangeBlocked" access="public" returntype="boolean" output="false"
		hint="True when a new / changed DateSent or PaymentDate lands in a locked month (an unchanged date is allowed)">
		<cfargument name="newDate" required="true" />
		<cfargument name="currentDate" required="false" default="" />
		<cfif sameDay(arguments.newDate, arguments.currentDate)>
			<cfreturn false />
		</cfif>
		<cfreturn isLockedPeriod(arguments.newDate) />
	</cffunction>

	<cffunction name="invoiceHasLockedPayments" access="public" returntype="boolean" output="false">
		<cfargument name="invoiceId" required="true" />
		<cfset var q = "" />
		<cfif NOT isLive() OR NOT IsNumeric(arguments.invoiceId) OR Val(arguments.invoiceId) LTE 0>
			<cfreturn false />
		</cfif>
		<cfquery name="q" datasource="#Application.DataSrc#">
			SELECT 1 FROM #Request.prefix_db_agency#.Payments
			WHERE Invoice_ID = <cfqueryparam value="#Val(arguments.invoiceId)#" cfsqltype="cf_sql_integer">
			AND lock_date IS NOT NULL
			AND IFNULL(Deleted, 0) <> 1
			LIMIT 1
		</cfquery>
		<cfreturn q.recordcount GT 0 />
	</cffunction>

	<cffunction name="isLockedPeriod" access="public" returntype="boolean" output="false"
		hint="True when a DateSent / PaymentDate falls in an already-locked month">
		<cfargument name="dateValue" required="true" />
		<cfif NOT isLive() OR NOT IsDate(arguments.dateValue)>
			<cfreturn false />
		</cfif>
		<cfreturn DateCompare(arguments.dateValue, lockThroughDate(), "d") LTE 0 />
	</cffunction>

	<cffunction name="sameDay" access="public" returntype="boolean" output="false">
		<cfargument name="a" required="true" />
		<cfargument name="b" required="true" />
		<cfif NOT IsDate(arguments.a) OR NOT IsDate(arguments.b)>
			<cfreturn false />
		</cfif>
		<cfreturn DateCompare(arguments.a, arguments.b, "d") EQ 0 />
	</cffunction>

	<cffunction name="lockedRecordMessage" access="public" returntype="string" output="false">
		<cfargument name="kind" type="string" required="true" hint="Invoice or Payment" />
		<cfargument name="lockDate" required="false" default="" />
		<cfset var msg = "This #arguments.kind# is in a locked billing month" />
		<cfif IsDate(arguments.lockDate)>
			<cfset msg = msg & " (locked through #DateFormat(arguments.lockDate, 'mm/dd/yyyy')#)" />
		</cfif>
		<cfreturn msg & " and cannot be changed or deleted. Use Copy for Adjustment to make the change in the current month." />
	</cffunction>

	<cffunction name="lockedPeriodMessage" access="public" returntype="string" output="false">
		<cfargument name="label" type="string" required="false" default="Date" />
		<cfreturn "#arguments.label# cannot be on or before #DateFormat(lockThroughDate(), 'mm/dd/yyyy')#: that billing month is locked. Use a later date." />
	</cffunction>

	<cffunction name="arAdjustments" access="public" returntype="query" output="false"
		hint="Open adjustment invoices for the AR reports. They have no visits, so the visit-based AR queries never see them:
		      balance = the adjustment amount (ContractTotal) less payments / write-offs on the copy as of dt, aged by its own DateSent">
		<cfargument name="df" required="false" default="" hint="DateSent from" />
		<cfargument name="dt" required="false" default="" hint="DateSent to and the as-of date for payments (blank = today)" />
		<cfargument name="invoiceType" required="false" default="oasis" hint="oasis | non_oasis | all" />
		<cfargument name="payerId" required="false" default="" />
		<cfargument name="patientId" required="false" default="" />
		<cfargument name="minAge" required="false" default="" hint="days since DateSent, as the AR details age filter" />
		<cfset var q = QueryNew("Invoice_ID,adjusted_from_id,Pay_ID,Pay_Name,PayerSource,Patient_ID,Pt_Last,Pt_First,Pt_Agy_ID,Pt_DOB,DateSent,adj_amount,paid,balance,AR_age") />
		<cfset var asOf = IsDate(arguments.dt) ? arguments.dt : Now() />
		<cfif NOT isLive()>
			<cfreturn q />
		</cfif>
		<cfquery name="q" datasource="#Application.DataSrc#">
			SELECT * FROM (
				SELECT i.Invoice_ID, i.adjusted_from_id, py.Pay_ID, py.Pay_Name, py.PayerSource,
					p.Patient_ID, p.Pt_Last, p.Pt_First, p.Pt_Agy_ID, p.Pt_DOB, i.DateSent,
					ROUND(IFNULL(i.ContractTotal, 0), 2) AS adj_amount,
					ROUND(IFNULL(pay.paid, 0), 2) AS paid,
					ROUND(IFNULL(i.ContractTotal, 0) - IFNULL(pay.paid, 0), 2) AS balance,
					DATEDIFF(<cfqueryparam value="#asOf#" cfsqltype="cf_sql_date">, i.DateSent) AS AR_age
				FROM #Request.prefix_db_agency#.Invoices i
				JOIN #Request.prefix_db_agency#.pPayer py ON py.Pay_ID = i.PatientPayer_ID AND py.Status = 0
				JOIN #Request.prefix_db_agency#.pPatients p ON p.Patient_ID = i.Patient_ID AND p.Status IN (#Request.patient_status#)
				LEFT JOIN (
					SELECT Invoice_ID,
						SUM(IFNULL(Amount, 0) + CASE WHEN IFNULL(WriteOffType, '') LIKE 'PR%' THEN 0 ELSE IFNULL(WriteOffAmount, 0) END) AS paid
					FROM #Request.prefix_db_agency#.Payments
					WHERE Deleted <> 1
					AND PaymentDate <= <cfqueryparam value="#asOf#" cfsqltype="cf_sql_date">
					AND Invoice_ID IN (SELECT Invoice_ID FROM #Request.prefix_db_agency#.Invoices WHERE adjusted_from_id > 0)
					GROUP BY Invoice_ID
				) pay ON pay.Invoice_ID = i.Invoice_ID
				WHERE i.Agency_ID = <cfqueryparam value="#session.AgencyId#" cfsqltype="cf_sql_integer">
				AND i.Deleted <> 1
				AND i.adjusted_from_id > 0
				AND i.DateSent IS NOT NULL
				AND i.DateSent <= <cfqueryparam value="#asOf#" cfsqltype="cf_sql_date">
				<cfif IsDate(arguments.df)>
					AND i.DateSent >= <cfqueryparam value="#arguments.df#" cfsqltype="cf_sql_date">
				</cfif>
				<cfif arguments.invoiceType EQ "" OR arguments.invoiceType EQ "oasis">
					AND i.Assmt_ID > 0
				<cfelseif arguments.invoiceType EQ "non_oasis">
					AND i.Assmt_ID = 0
				</cfif>
				<cfif Val(arguments.payerId) GT 0>
					AND py.Pay_ID = <cfqueryparam value="#Val(arguments.payerId)#" cfsqltype="cf_sql_integer">
				</cfif>
				<cfif Val(arguments.patientId) GT 0>
					AND p.Patient_ID = <cfqueryparam value="#Val(arguments.patientId)#" cfsqltype="cf_sql_integer">
				</cfif>
				<cfif Val(arguments.minAge) GT 0>
					AND DATEDIFF(CURDATE(), i.DateSent) >= <cfqueryparam value="#Val(arguments.minAge)#" cfsqltype="cf_sql_integer">
				</cfif>
			) adj
			WHERE adj.balance <> 0
			ORDER BY adj.Pay_Name, adj.Pay_ID, adj.Pt_Last, adj.Pt_First, adj.Invoice_ID
		</cfquery>
		<cfreturn q />
	</cffunction>

	<cffunction name="copyInvoiceForAdjustment" access="public" returntype="numeric" output="false"
		hint="Copies a locked invoice to a new open invoice with every date cleared; returns the new Invoice_ID (0 if not copied)">
		<cfargument name="invoiceId" required="true" />
		<cfreturn copyRow("Invoices", "Invoice_ID", "Invoice_Lock", Val(arguments.invoiceId), "Comment", "Adjustment of Invoice ##" & Val(arguments.invoiceId)) />
	</cffunction>

	<cffunction name="copyPaymentForAdjustment" access="public" returntype="numeric" output="false"
		hint="Copies a locked payment to a new open payment on the same invoice with every date cleared; returns the new Payment_ID (0 if not copied)">
		<cfargument name="paymentId" required="true" />
		<cfreturn copyRow("Payments", "Payment_ID", "Payment_Lock", Val(arguments.paymentId), "Comments", "Adjustment of Payment ##" & Val(arguments.paymentId)) />
	</cffunction>

	<cffunction name="copyRow" access="private" returntype="numeric" output="false">
		<cfargument name="tableName" type="string" required="true" />
		<cfargument name="keyCol" type="string" required="true" />
		<cfargument name="lockFlagCol" type="string" required="true" />
		<cfargument name="sourceId" type="numeric" required="true" />
		<cfargument name="commentCol" type="string" required="true" />
		<cfargument name="commentPrefix" type="string" required="true" />
		<cfset var cols = "" />
		<cfset var src = "" />
		<cfset var ins = "" />
		<cfset var insertCols = [] />
		<cfset var selectExprs = [] />
		<cfset var colName = "" />
		<cfset var newId = 0 />

		<cfif NOT isLive() OR arguments.sourceId LTE 0>
			<cfreturn 0 />
		</cfif>

		<cfquery name="src" datasource="#Application.DataSrc#">
			SELECT t.#arguments.keyCol#
			FROM #Request.prefix_db_agency#.#arguments.tableName# t
			<cfif arguments.tableName EQ "Payments">
			JOIN #Request.prefix_db_agency#.Invoices i ON i.Invoice_ID = t.Invoice_ID
			</cfif>
			WHERE t.#arguments.keyCol# = <cfqueryparam value="#arguments.sourceId#" cfsqltype="cf_sql_integer">
			AND t.lock_date IS NOT NULL
			AND IFNULL(t.Deleted, 0) <> 1
			AND <cfif arguments.tableName EQ "Payments">i.Agency_ID<cfelse>t.Agency_ID</cfif> = <cfqueryparam value="#session.AgencyId#" cfsqltype="cf_sql_integer">
		</cfquery>
		<cfif src.recordcount EQ 0>
			<cfreturn 0 />
		</cfif>

		<cfquery name="cols" datasource="#Application.DataSrc#">
			SELECT COLUMN_NAME, DATA_TYPE
			FROM information_schema.COLUMNS
			WHERE TABLE_SCHEMA = <cfqueryparam value="#Request.prefix_db_agency#" cfsqltype="cf_sql_varchar">
			AND TABLE_NAME = <cfqueryparam value="#arguments.tableName#" cfsqltype="cf_sql_varchar">
			AND COLUMN_NAME NOT IN ('#arguments.keyCol#', 'cdate_cdarte')
			ORDER BY ORDINAL_POSITION
		</cfquery>

		<cfloop query="cols">
			<cfset colName = cols.COLUMN_NAME />
			<cfset ArrayAppend(insertCols, "`" & colName & "`") />
			<cfif colName EQ "lock_date">
				<cfset ArrayAppend(selectExprs, "NULL") />
			<cfelseif colName EQ arguments.lockFlagCol>
				<cfset ArrayAppend(selectExprs, "0") />
			<cfelseif colName EQ "adjusted_from_id">
				<cfset ArrayAppend(selectExprs, arguments.sourceId) />
			<cfelseif colName EQ "Record_Created_By">
				<cfset ArrayAppend(selectExprs, Val(session.EmployeeId)) />
			<cfelseif colName EQ "Record_Created_Date">
				<cfset ArrayAppend(selectExprs, "NOW()") />
			<cfelseif colName EQ "Record_Modified_By">
				<cfset ArrayAppend(selectExprs, "NULL") />
			<cfelseif colName EQ "Internal_control_number">
				<!--- 277 / 835 imports match invoices by control number and take the first row --->
				<cfset ArrayAppend(selectExprs, "''") />
			<cfelseif colName EQ arguments.commentCol>
				<cfset ArrayAppend(selectExprs, "CONCAT('" & arguments.commentPrefix & "', IF(IFNULL(`" & colName & "`, '') = '', '', CONCAT(' - ', `" & colName & "`)))") />
			<cfelseif ListFindNoCase("date,datetime,timestamp", cols.DATA_TYPE)>
				<cfset ArrayAppend(selectExprs, "NULL") />
			<cfelse>
				<cfset ArrayAppend(selectExprs, "`" & colName & "`") />
			</cfif>
		</cfloop>

		<cfset var selectSql = ArrayToList(selectExprs) />
		<cfquery datasource="#Application.DataSrc#" result="ins">
			INSERT INTO #Request.prefix_db_agency#.#arguments.tableName# (#ArrayToList(insertCols)#)
			SELECT #PreserveSingleQuotes(selectSql)#
			FROM #Request.prefix_db_agency#.#arguments.tableName#
			WHERE #arguments.keyCol# = <cfqueryparam value="#arguments.sourceId#" cfsqltype="cf_sql_integer">
		</cfquery>
		<cfif StructKeyExists(ins, "GENERATEDKEY")>
			<cfset newId = Val(ins.GENERATEDKEY) />
		<cfelseif StructKeyExists(ins, "GENERATED_KEY")>
			<cfset newId = Val(ins.GENERATED_KEY) />
		</cfif>
		<cfreturn newId />
	</cffunction>

</cfcomponent>
