use std::iter::FusedIterator;
use opensqlany::{ApModel, PageStore, PageType, Result as SaResult};
use crate::bv_recovery::{deobfuscate_with_bv, recover_bv_qb_data};
use crate::page_attribution::PageAttribution;
pub use crate::date::DATE_EPOCH_DAYS_BEFORE_UNIX;
const PAGE_DATA_END: usize = 0xFE0;
const ANCHOR_LEN: usize = 25;
const QB_ID_LEN: usize = 16;
const F32_ONE: [u8; 4] = [0x00, 0x00, 0x80, 0x3F];
const PARENT_PREFIX: [u8; 5] = [0x00, 0x00, 0x00, 0x00, 0x10];
#[derive(Debug, thiserror::Error)]
pub enum LineItemError {
#[error(transparent)]
Sa(#[from] opensqlany::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AmountType {
None,
OneByteOne,
Standard,
Deferred,
Other(u8),
}
impl AmountType {
pub fn from_byte(b: u8) -> Self {
match b {
0x00 => Self::None,
0x01 => Self::OneByteOne,
0x02 => Self::Standard,
0x03 => Self::Deferred,
other => Self::Other(other),
}
}
pub fn decode_cents(self, raw: &[u8; 4]) -> Option<u32> {
match self {
Self::Standard | Self::OneByteOne => {
let cents = (raw[1] as u32) | ((raw[2] as u32) << 8) | ((raw[3] as u32) << 16);
Some(cents)
}
_ => None,
}
}
pub fn decode_cents_signed(self, raw: &[u8; 4]) -> Option<i32> {
match self {
Self::Standard | Self::OneByteOne => {
let mag =
((raw[1] & 0x7F) as i32) | ((raw[2] as i32) << 8) | ((raw[3] as i32) << 16);
let sign = if raw[1] & 0x80 != 0 { -1 } else { 1 };
Some(sign * mag)
}
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct LineItem {
pub invoice_id: String,
pub page_number: u64,
pub page_offset: usize,
pub item_qb_id: Option<String>,
pub amount_type: AmountType,
pub amount_cents: Option<u32>,
pub amount_cents_signed: Option<i32>,
pub amount_raw: [u8; 4],
pub txn_date_raw: Option<u32>,
pub counter: Option<u32>,
pub source_table: Option<String>,
}
impl LineItem {
pub fn txn_date_days_since_unix(&self) -> Option<i64> {
self.txn_date_raw
.filter(|&d| d > 0 && d < 30_000)
.map(|d| d as i64 - DATE_EPOCH_DAYS_BEFORE_UNIX)
}
}
pub fn iter_lineitems<'a>(
store: &'a PageStore,
model: &'a ApModel,
) -> impl Iterator<Item = LineItem> + 'a {
LineItemIter::new(store, model, None)
}
pub fn iter_lineitems_with_attribution<'a>(
store: &'a PageStore,
model: &'a ApModel,
attribution: &'a PageAttribution,
) -> impl Iterator<Item = LineItem> + 'a {
LineItemIter::new(store, model, Some(attribution))
}
struct LineItemIter<'a> {
store: &'a PageStore,
model: &'a ApModel,
attribution: Option<&'a PageAttribution>,
pn: u64,
n_pages: u64,
buffer: Vec<LineItem>,
}
impl<'a> LineItemIter<'a> {
fn new(
store: &'a PageStore,
model: &'a ApModel,
attribution: Option<&'a PageAttribution>,
) -> Self {
Self {
store,
model,
attribution,
pn: 1, n_pages: store.page_count(),
buffer: Vec::new(),
}
}
fn fill_buffer(&mut self) -> SaResult<bool> {
while self.buffer.is_empty() && self.pn < self.n_pages {
let pn = self.pn;
self.pn += 1;
let page = self.store.page(pn)?;
if page.trailer().page_type() != PageType::Extent {
continue;
}
let raw = page.bytes();
let plain = match recover_bv_qb_data(pn, raw) {
Some(bv) => deobfuscate_with_bv(raw, pn, bv),
None => self.model.deobfuscate_with_store(raw, pn, self.store),
};
let before = self.buffer.len();
scan_page(&plain[..PAGE_DATA_END], pn, &mut self.buffer);
if let Some(attr) = self.attribution {
if let Some(entry) = attr.attribute(pn) {
let name = entry.name.clone();
for li in &mut self.buffer[before..] {
li.source_table = Some(name.clone());
}
}
}
}
Ok(!self.buffer.is_empty())
}
}
impl Iterator for LineItemIter<'_> {
type Item = LineItem;
fn next(&mut self) -> Option<Self::Item> {
loop {
if let Some(item) = self.buffer.pop() {
return Some(item);
}
match self.fill_buffer() {
Ok(true) => continue,
_ => return None,
}
}
}
}
impl FusedIterator for LineItemIter<'_> {}
fn scan_page(body: &[u8], pn: u64, out: &mut Vec<LineItem>) {
if body.len() < ANCHOR_LEN {
return;
}
let limit = body.len() - ANCHOR_LEN;
let mut start = Vec::new();
let mut pos = 0usize;
while pos <= limit {
if body[pos..pos + 5] != PARENT_PREFIX {
pos += 1;
continue;
}
let id_range = pos + 5..pos + 5 + QB_ID_LEN;
if !is_base62(&body[id_range.clone()]) {
pos += 1;
continue;
}
if body[pos + 5 + QB_ID_LEN..pos + ANCHOR_LEN] != F32_ONE {
pos += 1;
continue;
}
start.push(pos);
pos += ANCHOR_LEN;
}
for anchor in start.into_iter().rev() {
out.push(parse_anchor(body, pn, anchor));
}
}
fn parse_anchor(body: &[u8], pn: u64, anchor_start: usize) -> LineItem {
let id_start = anchor_start + 5;
let invoice_id = std::str::from_utf8(&body[id_start..id_start + QB_ID_LEN])
.expect("anchor guarded by is_base62")
.to_owned();
let payload_start = anchor_start + ANCHOR_LEN;
let mut amount_raw = [0u8; 4];
if payload_start + 4 <= body.len() {
amount_raw.copy_from_slice(&body[payload_start..payload_start + 4]);
}
let amount_type = AmountType::from_byte(amount_raw[0]);
let amount_cents = amount_type.decode_cents(&amount_raw);
let amount_cents_signed = amount_type.decode_cents_signed(&amount_raw);
let mut txn_date_raw = None;
let mut counter = None;
let end = (payload_start + 64).min(body.len().saturating_sub(8));
for off in (payload_start + 4)..end {
let d = u32::from_le_bytes([body[off], body[off + 1], body[off + 2], body[off + 3]]);
let c = u32::from_le_bytes([body[off + 4], body[off + 5], body[off + 6], body[off + 7]]);
if (13_000..20_000).contains(&d) && c > 0 && c < 1_000_000 {
txn_date_raw = Some(d);
counter = Some(c);
break;
}
}
let mut item_qb_id = None;
let back_start = anchor_start.saturating_sub(96);
let back_slice = &body[back_start..anchor_start];
if let Some(rel) = find_subslice(back_slice, &[0x04, 0x00, 0x10]) {
let id_off = rel + 3;
if id_off + QB_ID_LEN <= back_slice.len() {
let id_bytes = &back_slice[id_off..id_off + QB_ID_LEN];
if is_base62(id_bytes) {
item_qb_id = Some(std::str::from_utf8(id_bytes).unwrap().to_owned());
}
}
}
LineItem {
invoice_id,
page_number: pn,
page_offset: anchor_start,
item_qb_id,
amount_type,
amount_cents,
amount_cents_signed,
amount_raw,
txn_date_raw,
counter,
source_table: None,
}
}
fn is_base62(s: &[u8]) -> bool {
s.iter()
.all(|&b| matches!(b, b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'))
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
for i in 0..=haystack.len() - needle.len() {
if &haystack[i..i + needle.len()] == needle {
return Some(i);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn amount_type_classification() {
assert_eq!(AmountType::from_byte(0x00), AmountType::None);
assert_eq!(AmountType::from_byte(0x01), AmountType::OneByteOne);
assert_eq!(AmountType::from_byte(0x02), AmountType::Standard);
assert_eq!(AmountType::from_byte(0x03), AmountType::Deferred);
assert_eq!(AmountType::from_byte(0x42), AmountType::Other(0x42));
}
#[test]
fn cents_decoding_known_record() {
let raw = [0x02, 0x40, 0x0F, 0x04];
let t = AmountType::from_byte(raw[0]);
assert_eq!(t.decode_cents(&raw), Some(266_048));
}
#[test]
fn type_03_no_cents() {
let raw = [0x03, 0xBF, 0x5F, 0x63];
let t = AmountType::from_byte(raw[0]);
assert_eq!(t.decode_cents(&raw), None);
assert_eq!(t.decode_cents_signed(&raw), None);
}
#[test]
fn signed_decode_positive() {
let raw = [0x02, 0x40, 0x2D, 0x01];
let t = AmountType::from_byte(raw[0]);
assert_eq!(t.decode_cents_signed(&raw), Some(77_120));
}
#[test]
fn signed_decode_negative_pairs_with_positive() {
let pos = [0x02, 0x40, 0x2D, 0x01];
let neg = [0x02, 0xC0, 0x2D, 0x01];
let t = AmountType::from_byte(pos[0]);
let a = t.decode_cents_signed(&pos).unwrap();
let b = t.decode_cents_signed(&neg).unwrap();
assert_eq!(a + b, 0);
assert_eq!(a, 77_120);
assert_eq!(b, -77_120);
}
#[test]
fn signed_decode_large_magnitude() {
let raw = [0x02, 0x7F, 0xFF, 0xFF];
let t = AmountType::from_byte(raw[0]);
assert_eq!(t.decode_cents_signed(&raw), Some(16_777_087));
}
#[test]
fn base62_check() {
assert!(is_base62(b"0000000000001QBm"));
assert!(!is_base62(b"0000000000001QB!"));
assert!(!is_base62(b"0000000000001 QB"));
}
#[test]
fn scan_page_finds_synthetic_anchor() {
let mut body = vec![0xAAu8; PAGE_DATA_END];
let anchor_offset = 32;
body[anchor_offset..anchor_offset + 5].copy_from_slice(&PARENT_PREFIX);
body[anchor_offset + 5..anchor_offset + 5 + QB_ID_LEN].copy_from_slice(b"0000000000001QBm");
body[anchor_offset + 5 + QB_ID_LEN..anchor_offset + ANCHOR_LEN].copy_from_slice(&F32_ONE);
body[anchor_offset + ANCHOR_LEN..anchor_offset + ANCHOR_LEN + 4]
.copy_from_slice(&[0x02, 0x40, 0x0F, 0x04]);
body[anchor_offset + ANCHOR_LEN + 8..anchor_offset + ANCHOR_LEN + 12]
.copy_from_slice(&15511u32.to_le_bytes());
body[anchor_offset + ANCHOR_LEN + 12..anchor_offset + ANCHOR_LEN + 16]
.copy_from_slice(&12671u32.to_le_bytes());
let mut out = Vec::new();
scan_page(&body, 3679, &mut out);
assert_eq!(out.len(), 1);
let li = &out[0];
assert_eq!(li.invoice_id, "0000000000001QBm");
assert_eq!(li.amount_type, AmountType::Standard);
assert_eq!(li.amount_cents, Some(266_048));
assert_eq!(li.txn_date_raw, Some(15511));
assert_eq!(li.counter, Some(12671));
}
}