Files
ezbookkeeping/src/models/transaction.ts
T
2025-09-14 17:18:47 +08:00

799 lines
27 KiB
TypeScript

import { type PartialRecord, itemAndIndex } from '@/core/base.ts';
import type { Year1BasedMonth, TextualYearMonthDay, StartEndTime, WeekDay } from '@/core/datetime.ts';
import { type Coordinate, getNormalizedCoordinate } from '@/core/coordinate.ts';
import { TransactionType } from '@/core/transaction.ts';
import { Account, type AccountInfoResponse } from './account.ts';
import { TransactionCategory, type TransactionCategoryInfoResponse } from './transaction_category.ts';
import { TransactionTag, type TransactionTagInfoResponse } from './transaction_tag.ts';
import { TransactionPicture, type TransactionPictureInfoBasicResponse } from './transaction_picture_info.ts';
export class Transaction implements TransactionInfoResponse {
public id: string;
public timeSequenceId: string;
public type: number;
public expenseCategoryId: string = '';
public incomeCategoryId: string = '';
public transferCategoryId: string = '';
public time: number;
public timeZone?: string; // only in new transaction
public utcOffset: number;
public sourceAccountId: string;
public destinationAccountId: string;
public sourceAmount: number;
public destinationAmount: number;
public hideAmount: boolean;
public tagIds: string[];
public comment: string;
public editable: boolean;
private _pictures?: TransactionPicture[];
private _geoLocation?: TransactionGeoLocation;
private _category?: TransactionCategory; // only for displaying transaction
private _sourceAccount?: Account; // only for displaying transaction
private _destinationAccount?: Account; // only for displaying transaction
private _tags?: TransactionTag[]; // only for displaying transaction
private _gregorianCalendarYearDashMonthDashDay?: TextualYearMonthDay = undefined; // only for displaying transaction in transaction list
private _gregorianCalendarDayOfMonth?: number = undefined; // only for displaying transaction in transaction list
private _displayDayOfWeek?: WeekDay = undefined; // only for displaying transaction in transaction list
protected constructor(id: string, timeSequenceId: string, type: number, categoryId: string, time: number, timeZone: string | undefined, utcOffset: number, sourceAccountId: string, destinationAccountId: string, sourceAmount: number, destinationAmount: number, hideAmount: boolean, tagIds: string[], comment: string, editable: boolean) {
this.id = id;
this.timeSequenceId = timeSequenceId;
this.type = type;
this.time = time;
this.timeZone = timeZone;
this.utcOffset = utcOffset;
this.sourceAccountId = sourceAccountId;
this.destinationAccountId = destinationAccountId;
this.sourceAmount = sourceAmount;
this.destinationAmount = destinationAmount;
this.hideAmount = hideAmount;
this.tagIds = tagIds;
this.comment = comment;
this.editable = editable;
this.setCategoryId(categoryId);
}
public get pictures(): TransactionPictureInfoBasicResponse[] | undefined {
const ret: TransactionPictureInfoBasicResponse[] = [];
if (this._pictures) {
for (const picture of this._pictures) {
ret.push(picture);
}
}
return ret;
}
public get geoLocation(): TransactionGeoLocationResponse | undefined {
return this._geoLocation;
}
public set geoLocation(value: Coordinate) {
this._geoLocation = TransactionGeoLocation.of(value);
}
public get categoryId(): string {
return this.getCategoryId();
}
public get category(): TransactionCategoryInfoResponse | undefined {
return this._category;
}
public get sourceAccount(): AccountInfoResponse | undefined {
return this._sourceAccount;
}
public get destinationAccount(): AccountInfoResponse | undefined {
return this._destinationAccount;
}
public get tags(): TransactionTagInfoResponse[] | undefined {
const ret: TransactionTagInfoResponse[] = [];
if (this._tags) {
for (const tag of this._tags) {
ret.push(tag);
}
}
return ret;
}
public get gregorianCalendarYearDashMonthDashDay(): TextualYearMonthDay | undefined {
return this._gregorianCalendarYearDashMonthDashDay;
}
public get gregorianCalendarDayOfMonth(): number | undefined {
return this._gregorianCalendarDayOfMonth;
}
public get displayDayOfWeek(): WeekDay | undefined {
return this._displayDayOfWeek;
}
public getCategoryId(): string {
if (this.type === TransactionType.Expense) {
return this.expenseCategoryId;
} else if (this.type === TransactionType.Income) {
return this.incomeCategoryId;
} else if (this.type === TransactionType.Transfer) {
return this.transferCategoryId;
} else {
return '';
}
}
public setCategoryId(categoryId: string): void {
if (this.type === TransactionType.Expense) {
this.expenseCategoryId = categoryId;
} else if (this.type === TransactionType.Income) {
this.incomeCategoryId = categoryId;
} else if (this.type === TransactionType.Transfer) {
this.transferCategoryId = categoryId;
}
}
public setCategory(category?: TransactionCategory): void {
this._category = category;
}
public setSourceAccount(sourceAccount?: Account): void {
this._sourceAccount = sourceAccount;
}
public setDestinationAccount(destinationAccount?: Account): void {
this._destinationAccount = destinationAccount;
}
public setTags(tags: TransactionTag[]): void {
this._tags = tags;
}
public getPictureIds(): string[] {
const pictureIds: string[] = [];
if (this._pictures) {
for (const picture of this._pictures) {
pictureIds.push(picture.pictureId);
}
}
return pictureIds;
}
public setPictures(pictures: TransactionPicture[]): void {
this._pictures = pictures;
}
public addPicture(pictureInfo: TransactionPictureInfoBasicResponse): void {
if (!this._pictures) {
this._pictures = [];
}
this._pictures.push(TransactionPicture.of(pictureInfo));
}
public removePicture(pictureInfo: TransactionPictureInfoBasicResponse): void {
if (!this._pictures) {
return;
}
for (const [picture, index] of itemAndIndex(this._pictures)) {
if (picture.pictureId === pictureInfo.pictureId) {
this._pictures.splice(index, 1);
}
}
}
public clearPictures(): void {
this._pictures = [];
}
public getNormalizedGeoLocation(): Coordinate | undefined {
if (!this._geoLocation) {
return undefined;
}
return this._geoLocation.toNormalizedCoordinate();
}
public setGeoLocation(geoLocation?: Coordinate): void {
if (geoLocation) {
this._geoLocation = TransactionGeoLocation.createNewGeoLocation(geoLocation.latitude, geoLocation.longitude);
} else {
this._geoLocation = undefined;
}
}
public setLatitudeAndLongitude(latitude: number, longitude: number): void {
this._geoLocation = TransactionGeoLocation.createNewGeoLocation(latitude, longitude);
}
public removeGeoLocation(): void {
this._geoLocation = undefined;
}
public setDisplayDate(gregorianCalendarYearDashMonthDashDay: TextualYearMonthDay, gregorianCalendarDayOfMonth: number, displayDayOfWeek: WeekDay): void {
this._gregorianCalendarYearDashMonthDashDay = gregorianCalendarYearDashMonthDashDay;
this._gregorianCalendarDayOfMonth = gregorianCalendarDayOfMonth;
this._displayDayOfWeek = displayDayOfWeek;
}
public toCreateRequest(clientSessionId: string, actualTime?: number): TransactionCreateRequest {
return {
type: this.type,
categoryId: this.getCategoryId(),
time: actualTime ? actualTime : this.time,
utcOffset: this.utcOffset,
sourceAccountId: this.sourceAccountId,
destinationAccountId: this.type === TransactionType.Transfer ? this.destinationAccountId : '0',
sourceAmount: this.sourceAmount,
destinationAmount: this.type === TransactionType.Transfer ? this.destinationAmount : 0,
hideAmount: this.hideAmount,
tagIds: this.tagIds,
pictureIds: this.getPictureIds(),
comment: this.comment,
geoLocation: this.getNormalizedGeoLocation(),
clientSessionId: clientSessionId
};
}
public toModifyRequest(actualTime?: number): TransactionModifyRequest {
let categoryId = this.getCategoryId();
if (this.type === TransactionType.ModifyBalance) {
categoryId = '0';
}
return {
id: this.id,
categoryId: categoryId,
time: actualTime ? actualTime : this.time,
utcOffset: this.utcOffset,
sourceAccountId: this.sourceAccountId,
destinationAccountId: this.type === TransactionType.Transfer ? this.destinationAccountId : '0',
sourceAmount: this.sourceAmount,
destinationAmount: this.type === TransactionType.Transfer ? this.destinationAmount : 0,
hideAmount: this.hideAmount,
tagIds: this.tagIds,
pictureIds: this.getPictureIds(),
comment: this.comment,
geoLocation: this.getNormalizedGeoLocation()
};
}
public toTransactionDraft(): TransactionDraft | null {
if (this.type !== TransactionType.Expense &&
this.type !== TransactionType.Income &&
this.type !== TransactionType.Transfer) {
return null;
}
return {
type: this.type,
categoryId: this.getCategoryId(),
sourceAccountId: this.sourceAccountId,
sourceAmount: this.sourceAmount,
destinationAccountId: this.type === TransactionType.Transfer ? this.destinationAccountId : '0',
destinationAmount: this.type === TransactionType.Transfer ? this.destinationAmount : 0,
hideAmount: this.hideAmount,
tagIds: this.tagIds,
pictures: this.pictures,
comment: this.comment,
};
}
public static createNewTransaction(type: number, time: number, timeZone: string, utcOffset: number): Transaction {
return new Transaction(
'', // id
'', // timeSequenceId
type, // type
'', // categoryId
time, // time
timeZone, // timeZone
utcOffset, // utcOffset
'', // sourceAccountId
'', // destinationAccountId
0, // sourceAmount
0, // destinationAmount
false, // hideAmount
[], // tagIds
'', // comment
true // editable
);
}
public static of(transactionResponse: TransactionInfoResponse): Transaction {
const transaction: Transaction = new Transaction(
transactionResponse.id,
transactionResponse.timeSequenceId,
transactionResponse.type,
transactionResponse.categoryId,
transactionResponse.time,
undefined, // only in new transaction
transactionResponse.utcOffset,
transactionResponse.sourceAccountId,
transactionResponse.destinationAccountId,
transactionResponse.sourceAmount,
transactionResponse.destinationAmount,
transactionResponse.hideAmount,
transactionResponse.tagIds,
transactionResponse.comment,
transactionResponse.editable
);
if (transactionResponse.category) {
transaction.setCategory(TransactionCategory.of(transactionResponse.category));
}
if (transactionResponse.sourceAccount) {
transaction.setSourceAccount(Account.of(transactionResponse.sourceAccount));
}
if (transactionResponse.destinationAccount) {
transaction.setDestinationAccount(Account.of(transactionResponse.destinationAccount));
}
if (transactionResponse.tags) {
transaction.setTags(TransactionTag.ofMulti(transactionResponse.tags));
}
if (transactionResponse.pictures) {
const pictures: TransactionPicture[] = [];
for (const picture of transactionResponse.pictures) {
pictures.push(TransactionPicture.of(picture));
}
transaction.setPictures(pictures);
}
if (transactionResponse.geoLocation) {
transaction.setLatitudeAndLongitude(transactionResponse.geoLocation.latitude, transactionResponse.geoLocation.longitude);
}
return transaction;
}
public static ofMulti(transactionResponses: TransactionInfoResponse[]): Transaction[] {
const transactions: Transaction[] = [];
for (const transactionResponse of transactionResponses) {
transactions.push(Transaction.of(transactionResponse));
}
return transactions;
}
public static ofDraft(transactionDraft?: TransactionDraft | null): Transaction | null {
if (!transactionDraft) {
return null;
}
if (transactionDraft.type !== TransactionType.Expense &&
transactionDraft.type !== TransactionType.Income &&
transactionDraft.type !== TransactionType.Transfer) {
return null;
}
const transaction: Transaction = new Transaction(
'', // id
'', // timeSequenceId
transactionDraft.type, // type
transactionDraft.categoryId ?? '', // categoryId
0, // time
undefined, // only in new transaction
0, // utcOffset
transactionDraft.sourceAccountId ?? '', // sourceAccountId
transactionDraft.destinationAccountId ?? '', // destinationAccountId
transactionDraft.sourceAmount ?? 0, // sourceAmount
transactionDraft.destinationAmount ?? 0, // destinationAmount
transactionDraft.hideAmount ?? false, // hideAmount
transactionDraft.tagIds ?? [], // tagIds
transactionDraft.comment ?? '', // comment
true // editable
);
if (transactionDraft.pictures) {
const pictures: TransactionPicture[] = [];
for (const picture of transactionDraft.pictures) {
pictures.push(TransactionPicture.of(picture));
}
transaction.setPictures(pictures);
}
return transaction;
}
}
export class TransactionGeoLocation implements TransactionGeoLocationRequest {
public latitude: number;
public longitude: number;
private constructor(latitude: number, longitude: number) {
this.latitude = latitude;
this.longitude = longitude;
}
public static createNewGeoLocation(latitude: number, longitude: number): TransactionGeoLocation {
return new TransactionGeoLocation(latitude, longitude);
}
public static of(coordinate: Coordinate): TransactionGeoLocation {
return new TransactionGeoLocation(coordinate.latitude, coordinate.longitude);
}
public toNormalizedCoordinate(): Coordinate {
return getNormalizedCoordinate(this);
}
}
export interface TransactionDraft {
readonly type?: number;
readonly categoryId?: string;
readonly sourceAccountId?: string;
readonly sourceAmount?: number;
readonly destinationAccountId?: string;
readonly destinationAmount?: number;
readonly hideAmount?: boolean;
readonly tagIds?: string[];
readonly pictures?: TransactionPictureInfoBasicResponse[];
readonly comment?: string;
}
export interface TransactionGeoLocationRequest {
readonly latitude: number;
readonly longitude: number;
}
export interface TransactionCreateRequest {
readonly type: number;
readonly categoryId: string;
readonly time: number;
readonly utcOffset: number;
readonly sourceAccountId: string;
readonly destinationAccountId: string;
readonly sourceAmount: number;
readonly destinationAmount: number;
readonly hideAmount: boolean;
readonly tagIds: string[];
readonly pictureIds: string[];
readonly comment: string;
readonly geoLocation?: TransactionGeoLocationRequest;
readonly clientSessionId: string;
}
export interface TransactionModifyRequest {
readonly id: string;
readonly categoryId: string;
readonly time: number;
readonly utcOffset: number;
readonly sourceAccountId: string;
readonly destinationAccountId: string;
readonly sourceAmount: number;
readonly destinationAmount: number;
readonly hideAmount: boolean;
readonly tagIds: string[];
readonly pictureIds: string[];
readonly comment: string;
readonly geoLocation?: TransactionGeoLocationRequest;
}
export interface TransactionDeleteRequest {
readonly id: string;
}
export interface TransactionImportRequest {
readonly transactions: TransactionCreateRequest[];
readonly clientSessionId: string;
}
export interface TransactionListByMaxTimeRequest {
readonly maxTime: number;
readonly minTime: number;
readonly count: number;
readonly page: number;
readonly withCount: boolean;
readonly type: number;
readonly categoryIds: string;
readonly accountIds: string;
readonly tagIds: string;
readonly tagFilterType: number;
readonly amountFilter: string;
readonly keyword: string;
}
export interface TransactionListInMonthByPageRequest {
readonly year: number;
readonly month: number; // 1-based (1 = January, 12 = December)
readonly type: number;
readonly categoryIds: string;
readonly accountIds: string;
readonly tagIds: string;
readonly tagFilterType: number;
readonly amountFilter: string;
readonly keyword: string;
}
export interface TransactionReconciliationStatementRequest {
readonly accountId: string;
readonly startTime: number;
readonly endTime: number;
}
export type TransactionGeoLocationResponse = Coordinate;
export interface TransactionInfoResponse {
readonly id: string;
readonly timeSequenceId: string;
readonly type: number;
readonly categoryId: string;
readonly category?: TransactionCategoryInfoResponse;
readonly time: number;
readonly utcOffset: number;
readonly sourceAccountId: string;
readonly sourceAccount?: AccountInfoResponse;
readonly destinationAccountId: string;
readonly destinationAccount?: AccountInfoResponse;
readonly sourceAmount: number;
readonly destinationAmount: number;
readonly hideAmount: boolean;
readonly tagIds: string[];
readonly tags?: TransactionTagInfoResponse[];
readonly pictures?: TransactionPictureInfoBasicResponse[];
readonly comment: string;
readonly geoLocation?: TransactionGeoLocationResponse;
readonly editable: boolean;
}
export interface TransactionStatisticRequest {
readonly startTime: number;
readonly endTime: number;
readonly tagIds: string;
readonly tagFilterType: number;
readonly keyword: string;
readonly useTransactionTimezone: boolean;
}
export interface YearMonthRangeRequest {
readonly startYearMonth: string;
readonly endYearMonth: string;
}
export interface TransactionStatisticTrendsRequest extends YearMonthRangeRequest {
readonly tagIds: string;
readonly tagFilterType: number;
readonly keyword: string;
readonly useTransactionTimezone: boolean;
}
export const ALL_TRANSACTION_AMOUNTS_REQUEST_TYPE = [
'today',
'thisWeek',
'thisMonth',
'thisYear',
'lastMonth',
'monthBeforeLastMonth',
'monthBeforeLast2Months',
'monthBeforeLast3Months',
'monthBeforeLast4Months',
'monthBeforeLast5Months',
'monthBeforeLast6Months',
'monthBeforeLast7Months',
'monthBeforeLast8Months',
'monthBeforeLast9Months',
'monthBeforeLast10Months'
] as const;
export type TransactionAmountsRequestType = typeof ALL_TRANSACTION_AMOUNTS_REQUEST_TYPE[number];
export const LATEST_12MONTHS_TRANSACTION_AMOUNTS_REQUEST_TYPES: TransactionAmountsRequestType[] = [
'monthBeforeLast10Months',
'monthBeforeLast9Months',
'monthBeforeLast8Months',
'monthBeforeLast7Months',
'monthBeforeLast6Months',
'monthBeforeLast5Months',
'monthBeforeLast4Months',
'monthBeforeLast3Months',
'monthBeforeLast2Months',
'monthBeforeLastMonth',
'lastMonth',
'thisMonth'
];
export interface TransactionAmountsRequestParams extends PartialRecord<TransactionAmountsRequestType, StartEndTime> {
readonly useTransactionTimezone: boolean;
today?: StartEndTime;
thisWeek?: StartEndTime;
thisMonth?: StartEndTime;
thisYear?: StartEndTime;
lastMonth?: StartEndTime;
monthBeforeLastMonth?: StartEndTime;
monthBeforeLast2Months?: StartEndTime;
monthBeforeLast3Months?: StartEndTime;
monthBeforeLast4Months?: StartEndTime;
monthBeforeLast5Months?: StartEndTime;
monthBeforeLast6Months?: StartEndTime;
monthBeforeLast7Months?: StartEndTime;
monthBeforeLast8Months?: StartEndTime;
monthBeforeLast9Months?: StartEndTime;
monthBeforeLast10Months?: StartEndTime;
}
export class TransactionAmountsRequest {
public readonly useTransactionTimezone: boolean;
public readonly query: string;
public constructor(useTransactionTimezone: boolean, query: string) {
this.useTransactionTimezone = useTransactionTimezone;
this.query = query;
}
public buildQuery(): string {
return `use_transaction_timezone=${this.useTransactionTimezone}` + (this.query.length ? '&query=' + this.query : '');
}
public static of(params: TransactionAmountsRequestParams): TransactionAmountsRequest {
const queryParams: string[] = [];
ALL_TRANSACTION_AMOUNTS_REQUEST_TYPE.forEach((type) => {
if (params[type]) {
queryParams.push(`${type}_${params[type].startTime}_${params[type].endTime}`);
}
});
return new TransactionAmountsRequest(params.useTransactionTimezone, (queryParams.length ? queryParams.join('|') : ''));
}
}
export interface TransactionInfoPageWrapperResponse {
readonly items: TransactionInfoResponse[];
readonly nextTimeSequenceId?: number;
readonly totalCount?: number;
}
export interface TransactionInfoPageWrapperResponse2 {
readonly items: TransactionInfoResponse[];
readonly totalCount: number;
}
export interface TransactionReconciliationStatementResponseItem extends TransactionInfoResponse {
readonly accountOpeningBalance: number;
readonly accountClosingBalance: number;
}
export interface TransactionReconciliationStatementResponse {
readonly transactions: TransactionReconciliationStatementResponseItem[];
readonly totalInflows: number;
readonly totalOutflows: number;
readonly openingBalance: number;
readonly closingBalance: number;
}
export interface TransactionPageWrapper {
readonly items: Transaction[];
readonly totalCount?: number;
}
export interface TransactionStatisticResponse {
readonly startTime: number;
readonly endTime: number;
readonly items: TransactionStatisticResponseItem[];
}
export interface TransactionStatisticResponseItem {
readonly categoryId: string;
readonly accountId: string;
readonly amount: number;
}
export interface TransactionStatisticTrendsResponseItem {
readonly year: number;
readonly month: number; // 1-based (1 = January, 12 = December)
readonly items: TransactionStatisticResponseItem[];
}
export interface YearMonthDataItem extends Year1BasedMonth, Record<string, unknown> {}
export interface YearMonthItems<T extends Year1BasedMonth> extends Record<string, unknown> {
readonly items: T[];
}
export interface SortableTransactionStatisticDataItem {
readonly name: string;
readonly displayOrders: number[];
readonly totalAmount: number;
}
export type TransactionStatisticDataItemType = 'category' | 'account' | 'total';
export interface TransactionStatisticDataItemBase extends SortableTransactionStatisticDataItem {
readonly name: string;
readonly type: TransactionStatisticDataItemType;
readonly id: string;
readonly icon: string;
readonly color: string;
readonly hidden: boolean;
readonly displayOrders: number[];
readonly totalAmount: number;
}
export interface TransactionCategoricalAnalysisData {
readonly totalAmount: number;
readonly items: TransactionCategoricalAnalysisDataItem[];
}
export interface TransactionCategoricalAnalysisDataItem extends Record<string, unknown> , TransactionStatisticDataItemBase {
readonly percent: number;
}
export interface TransactionTrendsAnalysisData {
readonly items: TransactionTrendsAnalysisDataItem[];
}
export interface TransactionTrendsAnalysisDataItem extends Record<string, unknown>, TransactionStatisticDataItemBase {
readonly items: TransactionTrendsAnalysisDataAmount[];
}
export interface TransactionTrendsAnalysisDataAmount extends Record<string, unknown>, Year1BasedMonth {
readonly year: number;
readonly month1base: number;
readonly totalAmount: number;
}
export type TransactionAmountsResponse = PartialRecord<TransactionAmountsRequestType, TransactionAmountsResponseItem>;
export interface TransactionAmountsResponseItem {
readonly startTime: number;
readonly endTime: number;
readonly amounts: TransactionAmountsResponseItemAmountInfo[];
}
export interface TransactionAmountsResponseItemAmountInfo {
readonly currency: string;
readonly incomeAmount: number;
readonly expenseAmount: number;
}
export type TransactionOverviewResponse = PartialRecord<TransactionAmountsRequestType, TransactionOverviewResponseItem>;
export type TransactionOverviewDisplayTime = PartialRecord<TransactionAmountsRequestType, TransactionOverviewDisplayTimeItem>;
export interface TransactionOverviewDisplayTimeItem {
readonly displayTime?: string;
readonly startTime?: string;
readonly endTime?: string;
}
export interface TransactionOverviewResponseItem {
readonly valid: boolean;
readonly incomeAmount: number;
readonly expenseAmount: number;
readonly incompleteIncomeAmount: boolean;
readonly incompleteExpenseAmount: boolean;
readonly amounts?: TransactionAmountsResponseItemAmountInfo[];
}
export interface TransactionMonthlyIncomeAndExpenseData {
readonly monthStartTime: number;
readonly incomeAmount: number;
readonly expenseAmount: number;
readonly incompleteIncomeAmount: boolean;
readonly incompleteExpenseAmount: boolean;
}
export const EMPTY_TRANSACTION_RESULT: TransactionPageWrapper = {
items: [],
totalCount: 0
}