0.1.4Updated 6 months ago
// deno-lint-ignore-file ban-types

import dayjs from "npm:dayjs";
import State from "@infinity-beyond/modules/state.ts";

import { Entity, run_on_worker } from "@infinity-beyond/classes/data_types/entity/mod.ts";

import {
  AddLedgerEntry,
  ConsumeFromLedger,
  CreateAddLedgerEntryFunction,
  CreateConsumptionFunction,
  CreateEntryDataFunction,
  CreateUserDataFunction,
  GetLedgerEntryData,
  GetLedgerUserData,
  LedgerMeta,
  LedgerPermissions,
  LedgerRest,
} from "@ledger/mod.ts";

export class Ledger<
  EntityName extends string,
  PEMC extends Entity.Meta.Config = {},
> extends Entity<
  Ledger.Events,
  EntityName,
  Ledger.Permissions<EntityName>,
  Ledger.Meta,
  PEMC,
  Ledger.Options<PEMC>
> implements Ledger.Ledger<EntityName> {
  readonly REST: LedgerRest<EntityName>;

  protected static override Permissions: readonly string[] = LedgerPermissions;

  constructor(
    name: EntityName,
    options: Ledger.Options<PEMC> = {} as Ledger.Options<PEMC>,
  ) {
    super(
      name,
      options,
      LedgerMeta as Ledger.Meta,
    );

    this.options.expiration ||= {};
    this.options.rest ||= {};

    this.REST = new LedgerRest(this);
  }

  format_and_validate(key: string) {
    return this.validate_key(this.format_key(key));
  }

  format_key(key: string) {
    return this.options.key_formatter?.(key) ?? key;
  }

  validate_key(key: string): ValidateKeyResponse {
    if (!this.options.key_validator) {
      return { key, valid: true, reason: null };
    }

    const validation_result = this.options.key_validator(key);

    if (validation_result === true) {
      return { key, valid: true, reason: null };
    }

    if (validation_result === false) {
      return {
        key,
        valid: false,
        reason: "Unspecified: Validator did not return a failure reason.",
      };
    }

    return { key, ...validation_result };
  }

  @run_on_worker
  async AddEntry<S = EntityName>(
    params: Ledger.Entry.Unentered & Ledger.Expiration.Override,
  ): Promise<Ledger.Entry.Response> {
    params.key = this.format_key(params.key);
    const { valid, reason } = this.validate_key(params.key);

    if (!valid) {
      this.emit(
        "entry_creation_failed",
        reason,
        params as With<typeof params, { remaining: number }>,
      );

      return {
        added: false,
        reason,
      };
    }

    if (params.amount < 0 && !this.options.allow_negative_balances) {
      const reason =
        `adding negative rows is not allowed for [${this.name}]. Use 'ConsumeEntries' instead.`;

      this.emit(
        "entry_creation_failed",
        reason,
        params as With<typeof params, { remaining: number }>,
      );

      return {
        added: false,
        reason,
      };
    }

    let expiration_date: Date | undefined;
    if (this.options.expiration?.entries_expire) {
      const { custom_calculator, period, round_up_to } = this.options.expiration;

      expiration_calc: {
        if (typeof custom_calculator === "function") {
          expiration_date = custom_calculator();
          break expiration_calc;
        }

        if (period) {
          let expiration = dayjs().add(period[0], period[1]);
          if (round_up_to && round_up_to !== "none") {
            expiration = expiration.endOf(round_up_to);
          }
          expiration_date = expiration.toDate();
        }
      }

      if (!params.override_expiration_date) {
        params.expiration_date = expiration_date;
      }

      params.expired = params.expiration_date && dayjs().isAfter(params.expiration_date);
    } else {
      params.expiration_date = undefined;
    }

    delete params.override_expiration_date;

    params.remaining = params.amount;

    const {rows: [{
      new_entry_id = null,
      new_entry_timestamp = new Date(),
      new_balance = 0 } = {}
    ]} = await AddLedgerEntry.populate({
      entries_table: this.entries_table,
    }).execute(
      State.PostgresClient,
      params.key,
      params.amount,
      params.remaining,
      params.reason,
      params.description,
      params.reference,
      params.meta,
      params.source,
      params.request_id,
      params.expiration_date,
      !!params.expired,
    );

    if (!new_entry_id) {
      const reason = `Could not insert into ${this.entries_table}!`;

      this.emit(
        "entry_creation_failed",
        reason,
        params as With<typeof params, { remaining: number }>,
      );

      return {
        added: false,
        reason,
      };
    }

    const entry = params as Ledger.Entry;

    entry.id = new_entry_id;
    entry.timestamp = new_entry_timestamp;

    this.emit("entry_created", entry);

    return {
      added: true,
      balance: new_balance,
    };
  }

  @run_on_worker
  async ConsumeEntries(
    params: Ledger.Entry.Unentered,
  ): Promise<Ledger.Consumption.ConsumptionResponse> {
    params.key = this.format_key(params.key);
    const key_validation = this.validate_key(params.key);

    if (!key_validation.valid) {
      this.emit(
        "entry_creation_failed",
        key_validation.reason,
        params as With<typeof params, { remaining: number }>,
      );

      return {
        key: params.key,
        success: false,
        reason: key_validation.reason,
        params,
      };
    }

    let should_run_consumption = true;
    let failure_reason: string =
      'Consumption cancelled by `.on("before-consumption")` event';

    let success_callback: Function = () => Promise<void>;
    await this.emit("before-consumption", params, (reason) => {
      should_run_consumption = false;
      if (reason) failure_reason = reason;
    }, (callback) => {
      success_callback = callback;
    });

    if (!should_run_consumption) {
      return {
        key: params.key,
        success: false,
        reason: failure_reason,
        params,
      };
    }

    let rows: Ledger.Consumption.Function.Response[];
    try {
      rows = (await ConsumeFromLedger.populate({
        slug: this.slug,
      }).execute(
        State.PostgresClient,
        params.key,
        params.amount,
        params.source,
        params.reference,
        params.description,
        params.request_id,
      )).rows;
    } catch (e: any) {
      return {
        key: params.key,
        success: false,
        reason: e.message || "Unspecified error during consumption query",
        params,
      };
    }

    const [{ new_entry_id, updated_entries, new_user_balance, error_message }] =
      rows;

    if (
      new_entry_id === undefined || new_entry_id === null ||
      updated_entries === undefined || updated_entries === null ||
      new_user_balance === undefined || new_user_balance === null
    ) {
      return {
        key: params.key,
        success: false,
        reason: error_message || "Unspecified error during consumption query",
        params,
        new_entry_id,
        updated_entries,
        new_user_balance,
      } as any;
    }

    if (typeof success_callback == "function") await success_callback?.();

    await this.emit("consumption", {
      new_entry_id,
      updated_entries,
      new_user_balance,
      error_message: error_message || null,
    }, params);

    return {
      key: params.key,
      success: true,
      balance: new_user_balance,
      consumption_data: {
        new_entry_id,
        updated_entries,
      } as Ledger.Consumption.Function.Response,
      params,
    };
  }

  @run_on_worker
  async History(count = 50, page = 1) {
    if (!Number.isSafeInteger(count)) count = 10;
    count = Math.max(1, count);

    const { rows: entries = [] } = await State.PostgresClient.query<
      Ledger.Entry
    >(
      `select * from ${this.entries_table} order by timestamp desc limit $1 offset $2`,
      [count, count * (page - 1)],
    );
    return entries;
  }

  @run_on_worker
  async UserData(
    key: string,
    count = 10,
    page = 1,
  ): Promise<Ledger.User.Response> {
    key = this.format_key(key);
    if (!this.validate_key(key).valid) {
      return null;
    }

    if (!Number.isSafeInteger(count)) count = 10;
    count = Math.max(1, count);

    const { rows: [{ history, entry_count, balance } = {}] } =
      await GetLedgerUserData.populate({
        slug: this.slug,
      }).execute(State.PostgresClient, key, count, count * (page - 1));

    return { history, entry_count, balance };
  }

  @run_on_worker
  async HistoryFor(key: string, count = 10, page = 1) {
    key = this.format_key(key);
    if (!this.validate_key(key).valid) {
      return [];
    }

    if (!Number.isSafeInteger(count)) count = 10;
    count = Math.max(1, count);

    const { rows: entries = [] } = await State.PostgresClient.query<
      Ledger.Entry
    >(
      `select * from ${this.entries_table} where key=$1 order by timestamp desc limit $2 offset $3`,
      [key, count, count * (page - 1)],
    );
    return entries;
  }

  @run_on_worker
  async EntryData(id: number) {
    const {
      rows: [{ entry, deducted_by_entries, deducted_from_entries } = {}],
    } = await GetLedgerEntryData.populate({
      slug: this.slug,
    }).execute(State.PostgresClient, id);

    if (!entry) return null;

    return {
      entry,
      deducted_by_entries,
      deducted_from_entries,
    } as Ledger.Entry.DataResponse;
  }

  @run_on_worker
  async BalanceFor(key: string): Promise<number | undefined> {
    key = this.format_key(key);
    if (!this.validate_key(key).valid) {
      return undefined;
    }

    const { rows: [{ balance = 0 } = {}] } = await State.PostgresClient.query<
      Ledger.User
    >(`select balance from ${this.user_table} where key=$1`, [key]);
    return balance;
  }

  @run_on_worker
  async Balances(
    ...keys: string[]
  ): Promise<Pick<Ledger.User, "key" | "balance">[]> {
    const { rows } = await State.PostgresClient.query<Ledger.User>(
      `select balance from ${this.user_table} where key = ANY($1)`,
      [keys],
    );
    return rows.map(({ key, balance }) => ({ key, balance }));
  }

  @run_on_worker
  async CountFor(key: string): Promise<number | undefined> {
    key = this.format_key(key);
    if (!this.validate_key(key).valid) {
      return undefined;
    }

    const { rows: [{ entry_count = 0 } = {}] } = await State.PostgresClient
      .query<{ entry_count: number }>(
        `select (addition_count + consumption_count) as entry_count from ${this.user_table} where key=$1`,
        [key],
      );
    return entry_count;
  }

  @run_on_worker
  async UserCount() {
    const { rows: [{ count = 0 } = {}] } = await State.PostgresClient.query<
      { count: number }
    >(`select count(*)::int as count from ${this.user_table}`);
    return count;
  }

  get entries_table() {
    return `${this.slug}_entries`;
  }

  get user_table() {
    return `${this.slug}_users`;
  }

  get consumption_table() {
    return `${this.slug}_consumption`;
  }

  override async Setup() {
    await super.Setup();
    if (State.IS_BUILDING) return this;

    // #region Create DB Tables

    const entries_table = this.entries_table;
    const key_max_length = this.options.key_max_length ?? 11;

    // #region - Entries Table

    await State.PostgresClient.CreateTable<Ledger.Entry>(entries_table, {
      id: "serial PRIMARY KEY",

      key: `varchar(${key_max_length}) NOT NULL`,

      amount: "int NOT NULL",
      remaining: "int NOT NULL",

      timestamp: "timestamptz NOT NULL default NOW()",

      reason: "text",
      description: "text",
      reference: "text",
      meta: "text",

      source: "text NOT NULL",
      request_id: "text",

      expiration_date: "timestamptz",
      expired: "boolean NOT NULL DEFAULT false",
    }, {
      async onCreate() {
        await State.PostgresClient.query(`
          CREATE INDEX ${entries_table}_key_idx ON ${entries_table} using hash(key);
          CREATE INDEX ${entries_table}_key_timestamp_idx ON ${entries_table} (key, timestamp desc);
        `);
      },
    });

    // #region - Users Table

    const user_table = this.user_table;

    await State.PostgresClient.CreateTable<Ledger.User>(user_table, {
      id: "SERIAL PRIMARY KEY",
      balance: "INT NOT NULL DEFAULT 0",
      addition_count: "INT NOT NULL default 0",
      consumption_count: "INT NOT NULL default 0",
      key: `VARCHAR(${key_max_length}) NOT NULL`,
      first_entry: "TIMESTAMPTZ NOT NULL DEFAULT NOW()",
      last_entry: "TIMESTAMPTZ NOT NULL DEFAULT NOW()",
    }, {
      async onCreate() {
        await State.PostgresClient.query(`
          CREATE UNIQUE INDEX ${user_table}_key_idx ON ${user_table} (key);
          CREATE INDEX ${user_table}_key_hash_idx ON ${user_table} using hash(key);
          CREATE INDEX ${user_table}_balance_idx ON ${user_table} (balance);
        `);
      },
    });

    // #region - Consumption Table

    const consumption_table = this.consumption_table;

    await State.PostgresClient.CreateTable<Ledger.Consumption>(
      consumption_table,
      {
        id: "SERIAL PRIMARY KEY",
        consumption_entry_id:
          `INT4 REFERENCES ${entries_table}(id) ON DELETE RESTRICT`,
        source_entry_id:
          `INT4 REFERENCES ${entries_table}(id) ON DELETE RESTRICT`,
        amount_deducted: `INT NOT NULL`,
        timestamp: `TIMESTAMPTZ DEFAULT NOW()`,
      },
      {
        async onCreate() {
          await State.PostgresClient.query(`
          CREATE INDEX ${consumption_table}_consumption_id_idx ON ${consumption_table} using hash(consumption_entry_id);
          CREATE INDEX ${consumption_table}_source_id_idx ON ${consumption_table} using hash(source_entry_id);
        `);
        },
      },
    );

    // #region Create DB Functions

    await CreateConsumptionFunction.populate({
      comsumptions_table: this.consumption_table,
      entries_table: this.entries_table,
      meta_table: this.meta_table,
      slug: this.slug,
      users_table: this.user_table,
    }).execute(State.PostgresClient);

    await CreateAddLedgerEntryFunction.populate({
      entries_table: this.entries_table,
      meta_table: this.meta_table,
      users_table: this.user_table,
    }).execute(State.PostgresClient);

    await CreateEntryDataFunction.populate({
      comsumptions_table: this.consumption_table,
      entries_table: this.entries_table,
      slug: this.slug,
    }).execute(State.PostgresClient);

    await CreateUserDataFunction.populate({
      entries_table: this.entries_table,
      slug: this.slug,
      user_table: this.user_table,
    }).execute(State.PostgresClient);

    return this;
  }
}

Ledger.on("ledger created", (instance) => {
  instance.entity_class.log(`Ledger initialized`);
});