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,
account_id INTEGER NOT NULL,
type TEXT NOT NULL DEFAULT 'BALANCE',
balance INTEGER NOT NULL,
value INTEGER NOT NULL,
currency TEXT NOT NULL,
CONSTRAINT fk_balance_record_account
CONSTRAINT fk_account_value_record_account
FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE
);

View File

@ -6,6 +6,8 @@ import account.model;
import util.money;
import history.model;
import std.datetime : SysTime;
interface AccountRepository {
Optional!Account findById(ulong id);
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);
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;
struct AccountType {
const string id;
const string name;
const bool debitsPositive;
immutable string id;
immutable string name;
immutable bool debitsPositive;
static AccountType fromId(string id) {
static foreach (t; ALL_ACCOUNT_TYPES) {
@ -28,17 +28,45 @@ enum AccountTypes : AccountType {
immutable(AccountType[]) ALL_ACCOUNT_TYPES = cast(AccountType[]) [ EnumMembers!AccountTypes ];
struct Account {
const ulong id;
const SysTime createdAt;
const bool archived;
const AccountType type;
const string numberSuffix;
const string name;
const Currency currency;
const string description;
immutable ulong id;
immutable SysTime createdAt;
immutable bool archived;
immutable AccountType type;
immutable string numberSuffix;
immutable string name;
immutable Currency currency;
immutable string description;
}
struct AccountCreditCardProperties {
const ulong account_id;
const long creditLimit;
immutable ulong account_id;
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) {
return Attachment(
row.peek!ulong(0),
SysTime.fromISOExtString(row.peek!string(1), UTC()),
parseISOTimestamp(row, 1),
row.peek!string(2),
row.peek!string(3),
row.peek!ulong(4),
row.peek!(ubyte[])(5)
parseBlob(row, 5)
);
}
}

View File

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

View File

@ -25,8 +25,18 @@ interface PropertiesRepository {
* gateway for all data access operations for a profile.
*/
interface ProfileDataSource {
import account.data : AccountRepository;
import account.data;
import transaction.data;
PropertiesRepository getPropertiesRepository();
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 {
import account.data;
import account.data_impl_sqlite;
import transaction.data;
import transaction.data_impl_sqlite;
const SCHEMA = import("schema.sql");
private const string dbPath;
@ -164,4 +166,28 @@ class SqliteProfileDataSource : ProfileDataSource {
AccountRepository getAccountRepository() {
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;
struct TransactionVendor {
const ulong id;
const string name;
const string description;
immutable ulong id;
immutable string name;
immutable string description;
}
struct TransactionCategory {
const ulong id;
const Optional!ulong parentId;
const string name;
const string description;
const string color;
immutable ulong id;
immutable Optional!ulong parentId;
immutable string name;
immutable string description;
immutable string color;
}
struct TransactionTag {
const ulong id;
const string name;
immutable ulong id;
immutable string name;
}
struct Transaction {
const ulong id;
immutable ulong id;
/// The time at which the transaction happened.
const SysTime timestamp;
immutable SysTime timestamp;
/// The time at which the transaction entity was saved.
const SysTime addedAt;
const ulong amount;
const Currency currency;
const string description;
const Optional!ulong vendorId;
const Optional!ulong categoryId;
immutable SysTime addedAt;
immutable ulong amount;
immutable Currency currency;
immutable string description;
immutable Optional!ulong vendorId;
immutable Optional!ulong categoryId;
}
struct TransactionLineItem {
const ulong id;
const ulong transactionId;
const long valuePerItem;
const ulong quantity;
const uint idx;
const string description;
const Optional!ulong categoryId;
immutable ulong id;
immutable ulong transactionId;
immutable long valuePerItem;
immutable ulong quantity;
immutable uint idx;
immutable string description;
immutable Optional!ulong categoryId;
}

View File

@ -2,3 +2,4 @@ module transaction;
public import transaction.data;
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 {
/// 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.
const ubyte fractionalDigits;
immutable ubyte fractionalDigits;
/// The ISO 4217 numeric code for the currency.
const ushort numericCode;
immutable ushort numericCode;
static Currency ofCode(S)(S code) if (isSomeString!S) {
if (code.length != 3) {
@ -50,8 +50,8 @@ unittest {
* so for example, with USD currency, a value of 123 indicates $1.23.
*/
struct MoneyValue {
const Currency currency;
const long value;
immutable Currency currency;
immutable long value;
int opCmp(in MoneyValue other) const {
if (other.currency != this.currency) return 0;

View File

@ -1,15 +1,18 @@
module util.sample_data;
import slf4d;
import handy_httpd.components.optional;
import auth;
import profile;
import account;
import transaction;
import util.money;
import std.random;
import std.conv;
import std.array;
import std.datetime;
void generateSampleData() {
UserRepository userRepo = new FileSystemUserRepository;
@ -20,7 +23,13 @@ void generateSampleData() {
const int userCount = uniform(5, 10);
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.");
}
@ -48,6 +57,29 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) {
for (int i = 0; i < accountCount; i++) {
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) {
@ -58,7 +90,6 @@ void generateRandomAccount(int idx, ProfileDataSource ds) {
AccountType type = choice(ALL_ACCOUNT_TYPES);
Currency currency = choice(ALL_CURRENCIES);
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(
type,
numberSuffix,
@ -66,4 +97,65 @@ void generateRandomAccount(int idx, ProfileDataSource ds) {
currency,
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;
import std.datetime;
import slf4d;
import handy_httpd.components.optional;
import d2sqlite3;
@ -31,7 +33,7 @@ Optional!T findOne(T, Args...)(Database db, string query, T function(Row) result
* Returns: An optional result.
*/
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);
ResultRange result = stmt.execute();
if (result.empty) return Optional!T.empty;
@ -123,3 +125,44 @@ T doTransaction(T)(Database db, T delegate() dg) {
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;
}