Added transaction generation to sample data generation in the API.

This commit is contained in:
Andrew Lalis 2024-09-27 11:35:08 -04:00
parent f5c75ccf35
commit 3fa2938f48
15 changed files with 450 additions and 57 deletions

View File

@ -141,9 +141,9 @@ CREATE TABLE account_value_record (
timestamp TEXT NOT NULL, timestamp TEXT NOT NULL,
account_id INTEGER NOT NULL, account_id INTEGER NOT NULL,
type TEXT NOT NULL DEFAULT 'BALANCE', type TEXT NOT NULL DEFAULT 'BALANCE',
balance INTEGER NOT NULL, value INTEGER NOT NULL,
currency TEXT NOT NULL, currency TEXT NOT NULL,
CONSTRAINT fk_balance_record_account CONSTRAINT fk_account_value_record_account
FOREIGN KEY (account_id) REFERENCES account(id) FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );

View File

@ -6,6 +6,8 @@ import account.model;
import util.money; import util.money;
import history.model; import history.model;
import std.datetime : SysTime;
interface AccountRepository { interface AccountRepository {
Optional!Account findById(ulong id); Optional!Account findById(ulong id);
Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description); Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description);
@ -17,3 +19,16 @@ interface AccountRepository {
void setCreditCardProperties(ulong id, in AccountCreditCardProperties props); void setCreditCardProperties(ulong id, in AccountCreditCardProperties props);
History getHistory(ulong id); History getHistory(ulong id);
} }
interface AccountJournalEntryRepository {
Optional!AccountJournalEntry findById(ulong id);
AccountJournalEntry insert(
SysTime timestamp,
ulong accountId,
ulong transactionId,
ulong amount,
AccountJournalEntryType type,
Currency currency
);
void deleteById(ulong id);
}

View File

@ -185,3 +185,63 @@ SQL",
); );
} }
} }
class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository {
private Database db;
this(Database db) {
this.db = db;
}
Optional!AccountJournalEntry findById(ulong id) {
return util.sqlite.findById(db, "account_journal_entry", &parseEntry, id);
}
AccountJournalEntry insert(
SysTime timestamp,
ulong accountId,
ulong transactionId,
ulong amount,
AccountJournalEntryType type,
Currency currency
) {
util.sqlite.update(
db,
"INSERT INTO account_journal_entry
(timestamp, account_id, transaction_id, amount, type, currency)
VALUES (?, ?, ?, ?, ?, ?)",
timestamp.toISOExtString(),
accountId,
transactionId,
amount,
type,
currency.code
);
ulong id = db.lastInsertRowid();
return findById(id).orElseThrow();
}
void deleteById(ulong id) {
util.sqlite.deleteById(db, "account_journal_entry", id);
}
static AccountJournalEntry parseEntry(Row row) {
string typeStr = row.peek!(string, PeekMode.slice)(5);
AccountJournalEntryType type;
if (typeStr == "CREDIT") {
type = AccountJournalEntryType.CREDIT;
} else if (typeStr == "DEBIT") {
type = AccountJournalEntryType.DEBIT;
} else {
throw new Exception("Invalid account journal entry type: " ~ typeStr);
}
return AccountJournalEntry(
row.peek!ulong(0),
SysTime.fromISOExtString(row.peek!string(1)),
row.peek!ulong(2),
row.peek!ulong(3),
row.peek!ulong(4),
type,
Currency.ofCode(row.peek!(string, PeekMode.slice)(6))
);
}
}

View File

@ -6,9 +6,9 @@ import std.traits : EnumMembers;
import util.money; import util.money;
struct AccountType { struct AccountType {
const string id; immutable string id;
const string name; immutable string name;
const bool debitsPositive; immutable bool debitsPositive;
static AccountType fromId(string id) { static AccountType fromId(string id) {
static foreach (t; ALL_ACCOUNT_TYPES) { static foreach (t; ALL_ACCOUNT_TYPES) {
@ -28,17 +28,45 @@ enum AccountTypes : AccountType {
immutable(AccountType[]) ALL_ACCOUNT_TYPES = cast(AccountType[]) [ EnumMembers!AccountTypes ]; immutable(AccountType[]) ALL_ACCOUNT_TYPES = cast(AccountType[]) [ EnumMembers!AccountTypes ];
struct Account { struct Account {
const ulong id; immutable ulong id;
const SysTime createdAt; immutable SysTime createdAt;
const bool archived; immutable bool archived;
const AccountType type; immutable AccountType type;
const string numberSuffix; immutable string numberSuffix;
const string name; immutable string name;
const Currency currency; immutable Currency currency;
const string description; immutable string description;
} }
struct AccountCreditCardProperties { struct AccountCreditCardProperties {
const ulong account_id; immutable ulong account_id;
const long creditLimit; immutable long creditLimit;
}
enum AccountJournalEntryType : string {
CREDIT = "CREDIT",
DEBIT = "DEBIT"
}
struct AccountJournalEntry {
immutable ulong id;
immutable SysTime timestamp;
immutable ulong accountId;
immutable ulong transactionId;
immutable ulong amount;
immutable AccountJournalEntryType type;
immutable Currency currency;
}
enum AccountValueRecordType : string {
BALANCE = "BALANCE"
}
struct AccountValueRecord {
immutable ulong id;
immutable SysTime timestamp;
immutable ulong accountId;
immutable AccountValueRecordType type;
immutable long value;
immutable Currency currency;
} }

View File

@ -58,11 +58,11 @@ SQL",
static Attachment parseAttachment(Row row) { static Attachment parseAttachment(Row row) {
return Attachment( return Attachment(
row.peek!ulong(0), row.peek!ulong(0),
SysTime.fromISOExtString(row.peek!string(1), UTC()), parseISOTimestamp(row, 1),
row.peek!string(2), row.peek!string(2),
row.peek!string(3), row.peek!string(3),
row.peek!ulong(4), row.peek!ulong(4),
row.peek!(ubyte[])(5) parseBlob(row, 5)
); );
} }
} }

View File

@ -3,10 +3,10 @@ module attachment.model;
import std.datetime; import std.datetime;
struct Attachment { struct Attachment {
ulong id; immutable ulong id;
SysTime uploadedAt; immutable SysTime uploadedAt;
string filename; immutable string filename;
string contentType; immutable string contentType;
ulong size; immutable ulong size;
ubyte[] content; immutable ubyte[] content;
} }

View File

@ -25,8 +25,18 @@ interface PropertiesRepository {
* gateway for all data access operations for a profile. * gateway for all data access operations for a profile.
*/ */
interface ProfileDataSource { interface ProfileDataSource {
import account.data : AccountRepository; import account.data;
import transaction.data;
PropertiesRepository getPropertiesRepository(); PropertiesRepository getPropertiesRepository();
AccountRepository getAccountRepository(); AccountRepository getAccountRepository();
AccountJournalEntryRepository getAccountJournalEntryRepository();
TransactionVendorRepository getTransactionVendorRepository();
TransactionCategoryRepository getTransactionCategoryRepository();
TransactionTagRepository getTransactionTagRepository();
TransactionRepository getTransactionRepository();
void doTransaction(void delegate () dg);
} }

View File

@ -141,6 +141,8 @@ class SqlitePropertiesRepository : PropertiesRepository {
class SqliteProfileDataSource : ProfileDataSource { class SqliteProfileDataSource : ProfileDataSource {
import account.data; import account.data;
import account.data_impl_sqlite; import account.data_impl_sqlite;
import transaction.data;
import transaction.data_impl_sqlite;
const SCHEMA = import("schema.sql"); const SCHEMA = import("schema.sql");
private const string dbPath; private const string dbPath;
@ -164,4 +166,28 @@ class SqliteProfileDataSource : ProfileDataSource {
AccountRepository getAccountRepository() { AccountRepository getAccountRepository() {
return new SqliteAccountRepository(db); return new SqliteAccountRepository(db);
} }
AccountJournalEntryRepository getAccountJournalEntryRepository() {
return new SqliteAccountJournalEntryRepository(db);
}
TransactionVendorRepository getTransactionVendorRepository() {
return new SqliteTransactionVendorRepository(db);
}
TransactionCategoryRepository getTransactionCategoryRepository() {
return new SqliteTransactionCategoryRepository(db);
}
TransactionTagRepository getTransactionTagRepository() {
return new SqliteTransactionTagRepository(db);
}
TransactionRepository getTransactionRepository() {
return new SqliteTransactionRepository(db);
}
void doTransaction(void delegate () dg) {
util.sqlite.doTransaction(db, dg);
}
} }

View File

@ -179,3 +179,59 @@ class SqliteTransactionTagRepository : TransactionTagRepository {
); );
} }
} }
class SqliteTransactionRepository : TransactionRepository {
private const TABLE_NAME = "\"transaction\"";
private Database db;
this(Database db) {
this.db = db;
}
Optional!Transaction findById(ulong id) {
return util.sqlite.findById(db, TABLE_NAME, &parseTransaction, id);
}
Transaction insert(
SysTime timestamp,
SysTime addedAt,
ulong amount,
Currency currency,
string description,
Optional!ulong vendorId,
Optional!ulong categoryId
) {
util.sqlite.update(
db,
"INSERT INTO " ~ TABLE_NAME ~ "
(timestamp, added_at, amount, currency, description, vendor_id, category_id)
VALUES (?, ?, ?, ?, ?, ?, ?)",
timestamp.toISOExtString(),
addedAt.toISOExtString(),
amount,
currency.code,
description,
vendorId.asNullable,
categoryId.asNullable
);
ulong id = db.lastInsertRowid();
return findById(id).orElseThrow();
}
void deleteById(ulong id) {
util.sqlite.deleteById(db, TABLE_NAME, id);
}
static Transaction parseTransaction(Row row) {
import std.typecons : Nullable;
return Transaction(
row.peek!ulong(0),
SysTime.fromISOExtString(row.peek!string(1)),
SysTime.fromISOExtString(row.peek!string(2)),
row.peek!ulong(3),
Currency.ofCode(row.peek!(string, PeekMode.slice)(4)),
row.peek!string(5),
Optional!(ulong).of(row.peek!(Nullable!ulong)(6)),
Optional!(ulong).of(row.peek!(Nullable!ulong)(7))
);
}
}

View File

@ -6,43 +6,43 @@ import std.datetime;
import util.money; import util.money;
struct TransactionVendor { struct TransactionVendor {
const ulong id; immutable ulong id;
const string name; immutable string name;
const string description; immutable string description;
} }
struct TransactionCategory { struct TransactionCategory {
const ulong id; immutable ulong id;
const Optional!ulong parentId; immutable Optional!ulong parentId;
const string name; immutable string name;
const string description; immutable string description;
const string color; immutable string color;
} }
struct TransactionTag { struct TransactionTag {
const ulong id; immutable ulong id;
const string name; immutable string name;
} }
struct Transaction { struct Transaction {
const ulong id; immutable ulong id;
/// The time at which the transaction happened. /// The time at which the transaction happened.
const SysTime timestamp; immutable SysTime timestamp;
/// The time at which the transaction entity was saved. /// The time at which the transaction entity was saved.
const SysTime addedAt; immutable SysTime addedAt;
const ulong amount; immutable ulong amount;
const Currency currency; immutable Currency currency;
const string description; immutable string description;
const Optional!ulong vendorId; immutable Optional!ulong vendorId;
const Optional!ulong categoryId; immutable Optional!ulong categoryId;
} }
struct TransactionLineItem { struct TransactionLineItem {
const ulong id; immutable ulong id;
const ulong transactionId; immutable ulong transactionId;
const long valuePerItem; immutable long valuePerItem;
const ulong quantity; immutable ulong quantity;
const uint idx; immutable uint idx;
const string description; immutable string description;
const Optional!ulong categoryId; immutable Optional!ulong categoryId;
} }

View File

@ -2,3 +2,4 @@ module transaction;
public import transaction.data; public import transaction.data;
public import transaction.model; public import transaction.model;
public import transaction.service;

View File

@ -0,0 +1,62 @@
module transaction.service;
import handy_httpd.components.optional;
import std.datetime;
import transaction.model;
import transaction.data;
import profile.data;
import util.money;
import account.model;
void addTransaction(
ProfileDataSource ds,
SysTime timestamp,
SysTime addedAt,
ulong amount,
Currency currency,
string description,
Optional!ulong vendorId,
Optional!ulong categoryId,
Optional!ulong creditedAccountId,
Optional!ulong debitedAccountId,
TransactionLineItem[] lineItems
// TODO: Add attachments and tags!
) {
if (creditedAccountId.isNull && debitedAccountId.isNull) {
throw new Exception("At least one account must be linked to a transaction.");
}
ds.doTransaction(() {
auto journalEntryRepo = ds.getAccountJournalEntryRepository();
auto txRepo = ds.getTransactionRepository();
Transaction tx = txRepo.insert(
timestamp,
addedAt,
amount,
currency,
description,
vendorId,
categoryId
);
if (creditedAccountId) {
journalEntryRepo.insert(
timestamp,
creditedAccountId.value,
tx.id,
amount,
AccountJournalEntryType.CREDIT,
currency
);
}
if (debitedAccountId) {
journalEntryRepo.insert(
timestamp,
debitedAccountId.value,
tx.id,
amount,
AccountJournalEntryType.DEBIT,
currency
);
}
});
}

View File

@ -8,11 +8,11 @@ import std.traits : isSomeString, EnumMembers;
*/ */
struct Currency { struct Currency {
/// The common 3-character code for the currency, like "USD". /// The common 3-character code for the currency, like "USD".
const char[3] code; immutable char[3] code;
/// The number of digits after the decimal place that the currency supports. /// The number of digits after the decimal place that the currency supports.
const ubyte fractionalDigits; immutable ubyte fractionalDigits;
/// The ISO 4217 numeric code for the currency. /// The ISO 4217 numeric code for the currency.
const ushort numericCode; immutable ushort numericCode;
static Currency ofCode(S)(S code) if (isSomeString!S) { static Currency ofCode(S)(S code) if (isSomeString!S) {
if (code.length != 3) { if (code.length != 3) {
@ -50,8 +50,8 @@ unittest {
* so for example, with USD currency, a value of 123 indicates $1.23. * so for example, with USD currency, a value of 123 indicates $1.23.
*/ */
struct MoneyValue { struct MoneyValue {
const Currency currency; immutable Currency currency;
const long value; immutable long value;
int opCmp(in MoneyValue other) const { int opCmp(in MoneyValue other) const {
if (other.currency != this.currency) return 0; if (other.currency != this.currency) return 0;

View File

@ -1,15 +1,18 @@
module util.sample_data; module util.sample_data;
import slf4d; import slf4d;
import handy_httpd.components.optional;
import auth; import auth;
import profile; import profile;
import account; import account;
import transaction;
import util.money; import util.money;
import std.random; import std.random;
import std.conv; import std.conv;
import std.array; import std.array;
import std.datetime;
void generateSampleData() { void generateSampleData() {
UserRepository userRepo = new FileSystemUserRepository; UserRepository userRepo = new FileSystemUserRepository;
@ -20,7 +23,13 @@ void generateSampleData() {
const int userCount = uniform(5, 10); const int userCount = uniform(5, 10);
for (int i = 0; i < userCount; i++) { for (int i = 0; i < userCount; i++) {
generateRandomUser(i, userRepo); try {
generateRandomUser(i, userRepo);
} catch (Throwable t) {
import std.stdio;
stderr.writeln(t);
throw t;
}
} }
info("Random sample data generation complete."); info("Random sample data generation complete.");
} }
@ -48,6 +57,29 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) {
for (int i = 0; i < accountCount; i++) { for (int i = 0; i < accountCount; i++) {
generateRandomAccount(i, ds); generateRandomAccount(i, ds);
} }
auto vendorRepo = ds.getTransactionVendorRepository();
const int vendorCount = uniform(5, 30);
for (int i = 0; i < vendorCount; i++) {
vendorRepo.insert("Test Vendor " ~ to!string(i), "Testing vendor for sample data.");
}
infoF!" Generated %d random vendors."(vendorCount);
auto tagRepo = ds.getTransactionTagRepository();
const int tagCount = uniform(5, 30);
for (int i = 0; i < tagCount; i++) {
tagRepo.insert("test-tag-" ~ to!string(i));
}
infoF!" Generated %d random tags."(tagCount);
auto categoryRepo = ds.getTransactionCategoryRepository();
const int categoryCount = uniform(5, 30);
for (int i = 0; i < categoryCount; i++) {
categoryRepo.insert(Optional!ulong.empty, "Test Category " ~ to!string(i), "Testing category.", "FFFFFF");
}
infoF!" Generated %d random categories."(categoryCount);
generateRandomTransactions(ds);
} }
void generateRandomAccount(int idx, ProfileDataSource ds) { void generateRandomAccount(int idx, ProfileDataSource ds) {
@ -58,7 +90,6 @@ void generateRandomAccount(int idx, ProfileDataSource ds) {
AccountType type = choice(ALL_ACCOUNT_TYPES); AccountType type = choice(ALL_ACCOUNT_TYPES);
Currency currency = choice(ALL_CURRENCIES); Currency currency = choice(ALL_CURRENCIES);
string description = "This is a testing account generated by util.sample_data.generateRandomAccount()."; string description = "This is a testing account generated by util.sample_data.generateRandomAccount().";
infoF!" Generating random account: %s, ...%s"(name, numberSuffix);
Account account = accountRepo.insert( Account account = accountRepo.insert(
type, type,
numberSuffix, numberSuffix,
@ -66,4 +97,65 @@ void generateRandomAccount(int idx, ProfileDataSource ds) {
currency, currency,
description description
); );
infoF!" Generated random account: %s, #%s"(name, numberSuffix);
}
void generateRandomTransactions(ProfileDataSource ds) {
const bool hasVendor = uniform01() > 0.3;
const bool hasCategory = uniform01() > 0.2;
const TransactionVendor[] vendors = ds.getTransactionVendorRepository.findAll();
const TransactionCategory[] categories = ds.getTransactionCategoryRepository()
.findAllByParentId(Optional!ulong.empty);
const TransactionTag[] tags = ds.getTransactionTagRepository().findAll();
const Account[] accounts = ds.getAccountRepository().findAll();
SysTime now = Clock.currTime(UTC());
SysTime timestamp = Clock.currTime(UTC()) - seconds(1);
for (int i = 0; i < 1000; i++) {
Optional!ulong vendorId;
if (hasVendor) {
vendorId = Optional!ulong.of(choice(vendors).id);
}
Optional!ulong categoryId;
if (hasCategory) {
categoryId = Optional!ulong.of(choice(categories).id);
}
Optional!ulong creditedAccountId;
Optional!ulong debitedAccountId;
Account primaryAccount = choice(accounts);
Optional!ulong secondaryAccount;
if (uniform01() < 0.25) {
foreach (acc; accounts) {
if (acc.id != primaryAccount.id && acc.currency == primaryAccount.currency) {
secondaryAccount.value = acc.id;
break;
}
}
}
if (uniform01() > 0.5) {
creditedAccountId = Optional!ulong.of(primaryAccount.id);
if (secondaryAccount) debitedAccountId = secondaryAccount;
} else {
debitedAccountId = Optional!ulong.of(primaryAccount.id);
if (secondaryAccount) creditedAccountId = secondaryAccount;
}
ulong value = uniform(0, 1_000_000);
addTransaction(
ds,
timestamp,
now,
value,
primaryAccount.currency,
"Test transaction " ~ to!string(i),
vendorId,
categoryId,
creditedAccountId,
debitedAccountId,
[]
);
infoF!" Generated transaction %d"(i);
timestamp -= seconds(uniform(10, 1_000_000));
}
} }

View File

@ -1,5 +1,7 @@
module util.sqlite; module util.sqlite;
import std.datetime;
import slf4d; import slf4d;
import handy_httpd.components.optional; import handy_httpd.components.optional;
import d2sqlite3; import d2sqlite3;
@ -31,7 +33,7 @@ Optional!T findOne(T, Args...)(Database db, string query, T function(Row) result
* Returns: An optional result. * Returns: An optional result.
*/ */
Optional!T findById(T)(Database db, string table, T function(Row) resultMapper, ulong id) { Optional!T findById(T)(Database db, string table, T function(Row) resultMapper, ulong id) {
Statement stmt = db.prepare("SELECT * FROM " ~ table); Statement stmt = db.prepare("SELECT * FROM " ~ table ~ " WHERE id = ?");
stmt.bind(1, id); stmt.bind(1, id);
ResultRange result = stmt.execute(); ResultRange result = stmt.execute();
if (result.empty) return Optional!T.empty; if (result.empty) return Optional!T.empty;
@ -123,3 +125,44 @@ T doTransaction(T)(Database db, T delegate() dg) {
throw e; throw e;
} }
} }
/**
* Executes a "SELECT COUNT..." query on a database.
* Params:
* db = The database to use.
* query = The query to use, which must return an integer as its sole result.
* args = Arguments to provide to the query.
* Returns: The count returned by the query.
*/
ulong count(Args...)(Database db, string query, Args args) {
Statement stmt = db.prepare(query);
stmt.bindAll(args);
ResultRange result = stmt.execute();
if (result.empty) return 0;
return result.front.peek!ulong(0);
}
/**
* Reads an ISO-8601 UTC timestamp from a result row.
* Params:
* row = The row to read from.
* idx = The column index in the row to read.
* Returns: The timestamp that was read.
*/
SysTime parseISOTimestamp(Row row, size_t idx) {
return SysTime.fromISOExtString(
row.peek!(string, PeekMode.slice)(idx),
UTC()
);
}
/**
* Reads a set of bytes from a result row.
* Params:
* row = The row to read from.
* idx = The column index in the row to read.
* Returns: The blob data that was read.
*/
immutable(ubyte[]) parseBlob(Row row, size_t idx) {
return row.peek!(ubyte[], PeekMode.slice)(idx).idup;
}