import { Entity, run_on_worker } from "../../../modules/entity.ts";
import State from "../../../modules/state.ts";
import { Sluggify } from "../../../utils/slug.ts";
import { LedgerBalance, LedgerEntry, LedgerEvents, LedgerOptions } from "./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;
}
}