0.1.1Updated 7 months ago
import { Entity, run_on_worker } from "@infinity-beyond/modules/entity.ts";
import State from "@infinity-beyond/modules/state.ts";
import { Sluggify } from "@infinity-beyond/utils/slug.ts";
import { LedgerBalance, LedgerEntry, LedgerEvents, LedgerOptions } from "@infinity-beyond/classes/data_types/ledger/ledger.types.ts"

export class Ledger extends Entity<LedgerEvents> {
  readonly allow_negative_balances: boolean
  readonly key_max_length: number;

  constructor(name: string, { allow_negative_balances, key_max_length }: LedgerOptions = {}) {
    super(name);

    this.allow_negative_balances = allow_negative_balances ?? false;
    this.key_max_length = key_max_length ?? 11;
  }

  /**
   * Add a new entry to the ledger, and updates the balance.
   * 
   * @param key The unique value referring to a user. Usually their `msisdn`
   * @param amount The amount to add to this user. Negative numbers will subtract from their balance.
   * @returns The new balance
   */
  @run_on_worker
  async AddEntry(params: Omit<LedgerEntry, 'id' | 'timestamp'>) {

    if(!this.allow_negative_balances && params.amount < 0) {
      const balance = await this.Balance(params.key);

      const reason = 'insufficient balance';
      this.emit('entry_creation_failed', reason, params);

      if(balance + params.amount < 0) return {
        added: false,
        reason,
      };
    }

    const fields: string[] = [];
    const placeholders: string[] = [];
    const values: any[] = [];
    for(const [key, value] of Object.entries(params)) {
      if(value) {
        fields.push(key);
        placeholders.push(`$${fields.length}`);
        values.push(value);
      }
    }

    const insert_entries_query = `INSERT INTO ${this.entries_table} (${fields.join(', ')}) values (${placeholders.join(', ')})`;

    const {
      rowCount: entry_insert_count,
      warnings: entry_insert_warnings
    } = await State.PostgresClient.query(insert_entries_query, values);

    if(entry_insert_count == 0) {
      const reason = `Could not insert into ${this.entries_table}!\n${entry_insert_warnings.map(w => w.message).join(', ')}`;

      this.emit('entry_creation_failed', reason, params);

      return {
        added: false,
        reason,
      };
    }

    const insert_balance_query = `INSERT INTO ${this.balance_table} (key, balance) values ($1, $2) ON CONFLICT (key) DO UPDATE SET balance = ${this.balance_table}.balance + excluded.balance`;

    const {
      rowCount: balance_insert_count,
      warnings: balance_insert_warnings
    } = await State.PostgresClient.query<LedgerBalance>(
      insert_balance_query, [params.key, params.amount]
    );

    if(balance_insert_count == 0) {
      const reason = `Could not update balance!\n[${balance_insert_warnings.map(w => w.message).join(', ')}]`;

      this.emit('entry_creation_failed', reason, params);

      return {
        added: false,
        reason,
      };
    }

    this.emit('entry_created', params);

    return {
      added: true
    }
  }

  /**
   * Get the ledger history for a user
   * 
   * @param key The unique value referring to a user. Usually their `msisdn`
   * @param limit How many entries should be retrieved?
   * 
   * Default: 10
   */
  @run_on_worker
  async History(key: string, count = 10) {
    if(!Number.isSafeInteger(count)) count = 10;
    count = Math.max(1, count);

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

  /**
   * Get the total balance for a user
   * 
   * @param key The unique value referring to a user. Usually their `msisdn`
   * @param limit How many entries should be retrieved?
   * 
   * Default: 10
   */
  @run_on_worker
  async Balance(key: string) {
    const { rows: [{ balance = 0 } = {}] } = await State.PostgresClient.query<LedgerBalance>(`select balance from ${this.balance_table} where key=$1`, [key]);
    return balance;
  }
  
  @run_on_worker
  async UserCount() {
    const { rows: [{ count = 0 } = {}] } = await State.PostgresClient.query<{ count: number }>(`select count(*)::int as count from ${this.balance_table}`);
    return count;
  }

  protected override get slug(): string {
    return Sluggify(this.name);
  }

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

  private get balance_table() {
    return `${this.slug}_balances`;
  }

  async Setup() {
    const entries_table = this.entries_table;
    const key_max_length = this.key_max_length;

    const slug = this.slug;

    await State.PostgresClient.CreateTable<LedgerEntry>(entries_table, {
      id: "serial PRIMARY KEY",
      amount: "int NOT NULL",
      key: `varchar(${key_max_length}) NOT NULL`,
      timestamp: "timestamptz default NOW()",
      description: 'text',
      reason: 'text',
      meta: 'text',
      request_id: 'text',
    }, {
      async onCreate() {
        await State.PostgresClient.query(`
          CREATE INDEX ${slug}_entries_key_idx ON ${entries_table} (key);
          CREATE INDEX ${slug}_entries_key_timestamp_idx ON ${entries_table} (key, timestamp desc);
        `);
      }
    });

    const balance_table = this.balance_table;

    await State.PostgresClient.CreateTable<LedgerBalance>(balance_table, {
      id: "serial PRIMARY KEY",
      balance: "int NOT NULL DEFAULT 0",
      key: `varchar(${key_max_length}) UNIQUE NOT NULL`,
    });

    return this;
  }
}