@tuturuuu/utils 0.0.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +305 -0
- package/biome.json +5 -0
- package/jsr.json +8 -8
- package/package.json +63 -32
- package/src/__tests__/ai-temp-auth.test.ts +309 -0
- package/src/__tests__/api-proxy-guard.test.ts +1451 -0
- package/src/__tests__/app-url.test.ts +270 -0
- package/src/__tests__/avatar-url.test.ts +97 -0
- package/src/__tests__/color-helper.test.ts +179 -0
- package/src/__tests__/constants.test.ts +351 -0
- package/src/__tests__/crypto.test.ts +107 -0
- package/src/__tests__/date-helper.test.ts +408 -0
- package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
- package/src/__tests__/format.test.ts +317 -0
- package/src/__tests__/html-sanitizer.test.ts +360 -0
- package/src/__tests__/interest-calculator.test.ts +336 -0
- package/src/__tests__/interest-detector.test.ts +222 -0
- package/src/__tests__/label-colors.test.ts +241 -0
- package/src/__tests__/name-helper.test.ts +158 -0
- package/src/__tests__/node-diff.test.ts +576 -0
- package/src/__tests__/notification-service.test.ts +210 -0
- package/src/__tests__/onboarding-helper.test.ts +331 -0
- package/src/__tests__/path-helper.test.ts +152 -0
- package/src/__tests__/permissions.test.tsx +81 -0
- package/src/__tests__/request-emoji-limit.test.ts +172 -0
- package/src/__tests__/search-helper.test.ts +51 -0
- package/src/__tests__/storage-display-name.test.ts +37 -0
- package/src/__tests__/storage-path.test.ts +238 -0
- package/src/__tests__/tag-utils.test.ts +205 -0
- package/src/__tests__/task-description-yjs-state.test.ts +581 -0
- package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
- package/src/__tests__/task-helper-create-task.test.ts +129 -0
- package/src/__tests__/task-helpers.test.ts +464 -0
- package/src/__tests__/task-overrides.test.ts +305 -0
- package/src/__tests__/task-reorder-cache.test.ts +74 -0
- package/src/__tests__/task-sort-keys.test.ts +36 -0
- package/src/__tests__/task-transformers.test.ts +62 -0
- package/src/__tests__/text-helper.test.ts +776 -0
- package/src/__tests__/time-helper.test.ts +70 -0
- package/src/__tests__/time-tracker-period.test.ts +55 -0
- package/src/__tests__/timezone.test.ts +117 -0
- package/src/__tests__/upstash-rest.test.ts +77 -0
- package/src/__tests__/uuid-helper.test.ts +133 -0
- package/src/__tests__/workspace-helper.test.ts +859 -0
- package/src/__tests__/workspace-limits.test.ts +255 -0
- package/src/__tests__/yjs-helper.test.ts +581 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
- package/src/abuse-protection/__tests__/edge.test.ts +136 -0
- package/src/abuse-protection/__tests__/index.test.ts +562 -0
- package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
- package/src/abuse-protection/backend-rate-limit.ts +44 -0
- package/src/abuse-protection/constants.ts +117 -0
- package/src/abuse-protection/edge.ts +223 -0
- package/src/abuse-protection/index.ts +1545 -0
- package/src/abuse-protection/reputation.ts +587 -0
- package/src/abuse-protection/types.ts +97 -0
- package/src/abuse-protection/user-agent.ts +124 -0
- package/src/abuse-protection/user-suspension.ts +231 -0
- package/src/ai-temp-auth.ts +315 -0
- package/src/api-proxy-guard.ts +965 -0
- package/src/app-url.ts +96 -0
- package/src/avatar-url.ts +64 -0
- package/src/break-duration.ts +84 -0
- package/src/calendar-auth-token.test.ts +37 -0
- package/src/calendar-auth-token.ts +19 -0
- package/src/calendar-sync-coordination.md +197 -0
- package/src/calendar-utils.test.ts +169 -0
- package/src/calendar-utils.ts +91 -0
- package/src/color-helper.ts +110 -0
- package/src/common/nextjs.tsx +99 -0
- package/src/common/scan.tsx +15 -0
- package/src/configs/reports.ts +160 -0
- package/src/constants.ts +85 -0
- package/src/crypto.ts +21 -0
- package/src/currencies.ts +97 -0
- package/src/date-helper.ts +313 -0
- package/src/editor/convert-to-task.ts +264 -0
- package/src/editor/index.ts +5 -0
- package/src/email/__tests__/client.test.ts +141 -0
- package/src/email/__tests__/validation.test.ts +46 -0
- package/src/email/client.ts +92 -0
- package/src/email/server.ts +128 -0
- package/src/email/validation.ts +11 -0
- package/src/encryption/__tests__/calendar-events.test.ts +411 -0
- package/src/encryption/__tests__/configuration.test.ts +114 -0
- package/src/encryption/__tests__/field-encryption.test.ts +232 -0
- package/src/encryption/__tests__/key-generation.test.ts +30 -0
- package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
- package/src/encryption/__tests__/test-helpers.ts +22 -0
- package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
- package/src/encryption/encryption-service.ts +343 -0
- package/src/encryption/index.ts +25 -0
- package/src/encryption/types.ts +57 -0
- package/src/exchange-rates.ts +49 -0
- package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
- package/src/feature-flags/core.ts +322 -0
- package/src/feature-flags/data.ts +16 -0
- package/src/feature-flags/default.ts +18 -0
- package/src/feature-flags/index.ts +7 -0
- package/src/feature-flags/requestable-features.ts +79 -0
- package/src/feature-flags/types.ts +4 -0
- package/src/fetcher.ts +2 -0
- package/src/finance/index.ts +4 -0
- package/src/finance/interest-calculator.ts +456 -0
- package/src/finance/interest-detector.ts +141 -0
- package/src/finance/transform-invoice-results.ts +219 -0
- package/src/finance/wallet-permissions.test.ts +169 -0
- package/src/finance/wallet-permissions.ts +82 -0
- package/src/format.ts +122 -3
- package/src/generated/platform-build-metadata.ts +11 -0
- package/src/hooks/use-platform.ts +64 -0
- package/src/html-sanitizer.ts +155 -0
- package/src/internal-domains.ts +497 -0
- package/src/keyboard-preset.ts +109 -0
- package/src/label-colors.ts +213 -0
- package/src/launchable-apps.test.ts +126 -0
- package/src/launchable-apps.ts +490 -0
- package/src/name-helper.ts +269 -0
- package/src/next-config.test.ts +234 -0
- package/src/next-config.ts +203 -0
- package/src/node-diff.ts +375 -0
- package/src/notification-service.ts +379 -0
- package/src/nova/scores/__tests__/calculate.test.ts +254 -0
- package/src/nova/scores/calculate.ts +132 -0
- package/src/nova/submissions/check-permission.ts +132 -0
- package/src/onboarding-helper.ts +213 -0
- package/src/path-helper.ts +93 -0
- package/src/permissions.tsx +1170 -0
- package/src/plan-helpers.test.ts +188 -0
- package/src/plan-helpers.ts +80 -0
- package/src/platform-release.test.ts +74 -0
- package/src/platform-release.ts +155 -0
- package/src/portless.ts +124 -0
- package/src/priority-styles.ts +42 -0
- package/src/request-emoji-limit.ts +335 -0
- package/src/search-helper.ts +18 -0
- package/src/search.test.ts +89 -0
- package/src/search.ts +355 -0
- package/src/storage-display-name.ts +30 -0
- package/src/storage-path.ts +147 -0
- package/src/tag-utils.ts +159 -0
- package/src/task/reorder.ts +245 -0
- package/src/task/transformers.ts +149 -0
- package/src/task-date-timezone.ts +133 -0
- package/src/task-description-content.ts +240 -0
- package/src/task-helper/board.ts +193 -0
- package/src/task-helper/bulk-actions.ts +564 -0
- package/src/task-helper/personal-external-staging.ts +21 -0
- package/src/task-helper/recycle-bin.ts +202 -0
- package/src/task-helper/relationships.ts +346 -0
- package/src/task-helper/shared.ts +109 -0
- package/src/task-helper/sort-keys.ts +337 -0
- package/src/task-helper/task-hooks-basic.ts +342 -0
- package/src/task-helper/task-hooks-move.ts +264 -0
- package/src/task-helper/task-operations.ts +278 -0
- package/src/task-helper.ts +12 -0
- package/src/task-helpers.ts +241 -0
- package/src/task-list-status.ts +62 -0
- package/src/task-overrides.ts +82 -0
- package/src/task-snapshot.ts +374 -0
- package/src/text-diff.ts +81 -0
- package/src/text-helper.ts +537 -0
- package/src/time-helper.ts +63 -0
- package/src/time-tracker-period.ts +73 -0
- package/src/timeblock-helper.ts +418 -0
- package/src/timezone.ts +190 -0
- package/src/timezones.json +1271 -0
- package/src/upstash-rest.ts +56 -0
- package/src/user-helper.ts +296 -0
- package/src/uuid-helper.ts +11 -0
- package/src/workspace-handle.ts +10 -0
- package/src/workspace-helper.ts +1408 -0
- package/src/workspace-limits.ts +68 -0
- package/src/yjs-helper.ts +217 -0
- package/src/yjs-task-description.ts +81 -0
- package/tsconfig.json +3 -5
- package/tsconfig.typecheck.json +33 -0
- package/vitest.config.ts +36 -0
- package/dist/index.d.ts +0 -8
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -2
- package/dist/index.mjs.map +0 -1
- package/eslint.config.mjs +0 -20
- package/rollup.config.js +0 -41
- package/src/index.ts +0 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { BookText, HelpCircle, Sparkles, Trophy } from '@tuturuuu/icons';
|
|
2
|
+
import { FEATURE_FLAGS } from './data';
|
|
3
|
+
import type { FeatureFlag } from './types';
|
|
4
|
+
|
|
5
|
+
export interface RequestableFeatureConfig {
|
|
6
|
+
flag: FeatureFlag;
|
|
7
|
+
name: string;
|
|
8
|
+
icon: typeof BookText; // Using one of the icons as the type base
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const REQUESTABLE_FEATURES = {
|
|
12
|
+
ai: {
|
|
13
|
+
flag: FEATURE_FLAGS.ENABLE_AI,
|
|
14
|
+
name: 'AI',
|
|
15
|
+
icon: Sparkles,
|
|
16
|
+
},
|
|
17
|
+
education: {
|
|
18
|
+
flag: FEATURE_FLAGS.ENABLE_EDUCATION,
|
|
19
|
+
name: 'Education',
|
|
20
|
+
icon: BookText,
|
|
21
|
+
},
|
|
22
|
+
quizzes: {
|
|
23
|
+
flag: FEATURE_FLAGS.ENABLE_QUIZZES,
|
|
24
|
+
name: 'Quizzes',
|
|
25
|
+
icon: HelpCircle,
|
|
26
|
+
},
|
|
27
|
+
challenges: {
|
|
28
|
+
flag: FEATURE_FLAGS.ENABLE_CHALLENGES,
|
|
29
|
+
name: 'Challenges',
|
|
30
|
+
icon: Trophy,
|
|
31
|
+
},
|
|
32
|
+
} as const satisfies Record<string, RequestableFeatureConfig>;
|
|
33
|
+
|
|
34
|
+
export type RequestableFeatureKey = keyof typeof REQUESTABLE_FEATURES;
|
|
35
|
+
|
|
36
|
+
// Type guard to check if a feature key is requestable
|
|
37
|
+
export function isRequestableFeature(
|
|
38
|
+
key: string
|
|
39
|
+
): key is RequestableFeatureKey {
|
|
40
|
+
return key in REQUESTABLE_FEATURES;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Get requestable feature config by key
|
|
44
|
+
export function getRequestableFeature(
|
|
45
|
+
key: RequestableFeatureKey
|
|
46
|
+
): RequestableFeatureConfig {
|
|
47
|
+
return REQUESTABLE_FEATURES[key];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Get all requestable feature keys
|
|
51
|
+
export function getRequestableFeatureKeys(): RequestableFeatureKey[] {
|
|
52
|
+
return Object.keys(REQUESTABLE_FEATURES) as RequestableFeatureKey[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Derive mappings from REQUESTABLE_FEATURES
|
|
56
|
+
export const REQUESTABLE_KEY_TO_FEATURE_FLAG = Object.entries(
|
|
57
|
+
REQUESTABLE_FEATURES
|
|
58
|
+
).reduce(
|
|
59
|
+
(acc, [key, config]) => {
|
|
60
|
+
acc[key as RequestableFeatureKey] = config.flag;
|
|
61
|
+
return acc;
|
|
62
|
+
},
|
|
63
|
+
{} as Record<RequestableFeatureKey, FeatureFlag>
|
|
64
|
+
);
|
|
65
|
+
export const FEATURE_FLAG_TO_REQUESTABLE_KEY = Object.entries(
|
|
66
|
+
REQUESTABLE_FEATURES
|
|
67
|
+
).reduce(
|
|
68
|
+
(acc, [key, config]) => {
|
|
69
|
+
acc[config.flag] = key as RequestableFeatureKey;
|
|
70
|
+
return acc;
|
|
71
|
+
},
|
|
72
|
+
{} as Partial<Record<FeatureFlag, RequestableFeatureKey>>
|
|
73
|
+
);
|
|
74
|
+
// Helper to get requestable key from feature flag
|
|
75
|
+
export function getRequestableKeyFromFeatureFlag(
|
|
76
|
+
flag: FeatureFlag
|
|
77
|
+
): RequestableFeatureKey | null {
|
|
78
|
+
return FEATURE_FLAG_TO_REQUESTABLE_KEY[flag] ?? null;
|
|
79
|
+
}
|
package/src/fetcher.ts
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interest Calculator for Momo/ZaloPay high-interest savings programs.
|
|
3
|
+
*
|
|
4
|
+
* Formula (from Momo documentation):
|
|
5
|
+
* Daily Interest = floor(Balance × (Annual Rate / 365))
|
|
6
|
+
*
|
|
7
|
+
* Key rules:
|
|
8
|
+
* - Interest is calculated daily, rounded DOWN (floor)
|
|
9
|
+
* - Interest compounds (added to balance for next day)
|
|
10
|
+
* - Interest only accrues on business days
|
|
11
|
+
* - New deposits have delayed interest start based on deposit day
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
DailyInterestResult,
|
|
16
|
+
InterestCalculationParams,
|
|
17
|
+
InterestCalculationResult,
|
|
18
|
+
InterestProjection,
|
|
19
|
+
InterestProjectionParams,
|
|
20
|
+
PendingDepositInfo,
|
|
21
|
+
WalletInterestRate,
|
|
22
|
+
} from '@tuturuuu/types';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if a date is a weekend (Saturday or Sunday)
|
|
26
|
+
*/
|
|
27
|
+
export function isWeekend(date: Date): boolean {
|
|
28
|
+
const day = date.getDay();
|
|
29
|
+
return day === 0 || day === 6; // Sunday = 0, Saturday = 6
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if a date is a Vietnamese holiday
|
|
34
|
+
*/
|
|
35
|
+
export function isHoliday(date: Date, holidays: Set<string>): boolean {
|
|
36
|
+
const dateStr = formatDateString(date);
|
|
37
|
+
return holidays.has(dateStr);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if a date is a business day (not weekend, not holiday)
|
|
42
|
+
*/
|
|
43
|
+
export function isBusinessDay(date: Date, holidays: Set<string>): boolean {
|
|
44
|
+
return !isWeekend(date) && !isHoliday(date, holidays);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the next business day from a given date
|
|
49
|
+
*/
|
|
50
|
+
export function getNextBusinessDay(date: Date, holidays: Set<string>): Date {
|
|
51
|
+
const next = new Date(date);
|
|
52
|
+
next.setDate(next.getDate() + 1);
|
|
53
|
+
|
|
54
|
+
while (!isBusinessDay(next, holidays)) {
|
|
55
|
+
next.setDate(next.getDate() + 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Format a Date to YYYY-MM-DD string
|
|
63
|
+
*/
|
|
64
|
+
export function formatDateString(date: Date): string {
|
|
65
|
+
const year = date.getFullYear();
|
|
66
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
67
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
68
|
+
return `${year}-${month}-${day}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse a YYYY-MM-DD string to Date (in local timezone)
|
|
73
|
+
*/
|
|
74
|
+
export function parseDateString(dateStr: string): Date {
|
|
75
|
+
const [year, month, day] = dateStr.split('-').map(Number);
|
|
76
|
+
return new Date(year!, month! - 1, day);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get the interest start date for a deposit based on Momo/ZaloPay rules.
|
|
81
|
+
*
|
|
82
|
+
* Rules:
|
|
83
|
+
* - Monday-Thursday deposit: Interest starts next business day
|
|
84
|
+
* - Friday deposit: Interest starts Monday (or next business day if Monday is holiday)
|
|
85
|
+
* - Saturday/Sunday deposit: Interest starts Tuesday (or next business day)
|
|
86
|
+
* - Deposit before holiday: Interest starts first business day after holiday
|
|
87
|
+
*/
|
|
88
|
+
export function getInterestStartDate(
|
|
89
|
+
depositDate: Date,
|
|
90
|
+
holidays: Set<string>
|
|
91
|
+
): Date {
|
|
92
|
+
// Interest always starts on the next business day after deposit
|
|
93
|
+
return getNextBusinessDay(depositDate, holidays);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Calculate the number of days until interest starts for a deposit
|
|
98
|
+
*/
|
|
99
|
+
export function getDaysUntilInterestStarts(
|
|
100
|
+
depositDate: Date,
|
|
101
|
+
holidays: Set<string>,
|
|
102
|
+
today: Date = new Date()
|
|
103
|
+
): number {
|
|
104
|
+
const startDate = getInterestStartDate(depositDate, holidays);
|
|
105
|
+
const diffTime = startDate.getTime() - today.getTime();
|
|
106
|
+
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
107
|
+
return Math.max(0, diffDays);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get the applicable rate for a given date from rate history
|
|
112
|
+
*/
|
|
113
|
+
export function getRateForDate(
|
|
114
|
+
date: Date,
|
|
115
|
+
rates: WalletInterestRate[]
|
|
116
|
+
): number | null {
|
|
117
|
+
// Sort rates by effective_from descending
|
|
118
|
+
const sortedRates = [...rates].sort(
|
|
119
|
+
(a, b) =>
|
|
120
|
+
parseDateString(b.effective_from).getTime() -
|
|
121
|
+
parseDateString(a.effective_from).getTime()
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
for (const rate of sortedRates) {
|
|
125
|
+
const fromDate = parseDateString(rate.effective_from);
|
|
126
|
+
const toDate = rate.effective_to
|
|
127
|
+
? parseDateString(rate.effective_to)
|
|
128
|
+
: null;
|
|
129
|
+
|
|
130
|
+
// Check if date is within range
|
|
131
|
+
if (date >= fromDate && (!toDate || date <= toDate)) {
|
|
132
|
+
return rate.annual_rate;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Calculate daily interest using Momo/ZaloPay formula.
|
|
141
|
+
* Daily Interest = floor(Balance × (Annual Rate / 365))
|
|
142
|
+
*/
|
|
143
|
+
export function calculateDailyInterest(
|
|
144
|
+
balance: number,
|
|
145
|
+
annualRate: number
|
|
146
|
+
): number {
|
|
147
|
+
if (balance <= 0 || annualRate <= 0) return 0;
|
|
148
|
+
|
|
149
|
+
// Convert rate from percentage to decimal
|
|
150
|
+
const rateDecimal = annualRate / 100;
|
|
151
|
+
|
|
152
|
+
// Calculate and floor
|
|
153
|
+
return Math.floor(balance * (rateDecimal / 365));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Calculate interest over a date range from transaction history.
|
|
158
|
+
*
|
|
159
|
+
* This handles:
|
|
160
|
+
* - Transaction-based balance changes
|
|
161
|
+
* - Interest start delay rules
|
|
162
|
+
* - Rate history changes
|
|
163
|
+
* - Business day calculations
|
|
164
|
+
* - Compounding (interest adds to balance)
|
|
165
|
+
*/
|
|
166
|
+
export function calculateInterest(
|
|
167
|
+
params: InterestCalculationParams
|
|
168
|
+
): InterestCalculationResult {
|
|
169
|
+
const {
|
|
170
|
+
transactions,
|
|
171
|
+
rates,
|
|
172
|
+
holidays: holidayArray,
|
|
173
|
+
fromDate,
|
|
174
|
+
toDate,
|
|
175
|
+
initialBalance = 0,
|
|
176
|
+
} = params;
|
|
177
|
+
|
|
178
|
+
const holidays = new Set<string>(holidayArray);
|
|
179
|
+
const dailyResults: DailyInterestResult[] = [];
|
|
180
|
+
|
|
181
|
+
// Sort transactions by date
|
|
182
|
+
const sortedTransactions = [...transactions].sort(
|
|
183
|
+
(a, b) =>
|
|
184
|
+
parseDateString(a.date).getTime() - parseDateString(b.date).getTime()
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// Build a map of transaction effects by date
|
|
188
|
+
// Key: date string, Value: array of { amount, interestStartDate }
|
|
189
|
+
const transactionEffects = new Map<
|
|
190
|
+
string,
|
|
191
|
+
Array<{ amount: number; interestStartDate: Date }>
|
|
192
|
+
>();
|
|
193
|
+
|
|
194
|
+
for (const tx of sortedTransactions) {
|
|
195
|
+
const txDate = parseDateString(tx.date);
|
|
196
|
+
const interestStartDate =
|
|
197
|
+
tx.amount > 0 ? getInterestStartDate(txDate, holidays) : txDate;
|
|
198
|
+
|
|
199
|
+
const dateStr = tx.date;
|
|
200
|
+
if (!transactionEffects.has(dateStr)) {
|
|
201
|
+
transactionEffects.set(dateStr, []);
|
|
202
|
+
}
|
|
203
|
+
transactionEffects.get(dateStr)!.push({
|
|
204
|
+
amount: tx.amount,
|
|
205
|
+
interestStartDate,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const currentDate = parseDateString(fromDate);
|
|
210
|
+
const endDate = parseDateString(toDate);
|
|
211
|
+
let balance = initialBalance;
|
|
212
|
+
let cumulativeInterest = 0;
|
|
213
|
+
let businessDaysCount = 0;
|
|
214
|
+
let nonBusinessDaysCount = 0;
|
|
215
|
+
|
|
216
|
+
// Track deposits and their interest eligibility
|
|
217
|
+
// Each deposit has: { amount, interestStartDate }
|
|
218
|
+
const depositTracker: Array<{ amount: number; interestStartDate: Date }> = [];
|
|
219
|
+
|
|
220
|
+
// Add initial balance as if deposited long ago (already earning)
|
|
221
|
+
if (initialBalance > 0) {
|
|
222
|
+
depositTracker.push({
|
|
223
|
+
amount: initialBalance,
|
|
224
|
+
interestStartDate: new Date(0), // Epoch - always eligible
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
while (currentDate <= endDate) {
|
|
229
|
+
const dateStr = formatDateString(currentDate);
|
|
230
|
+
const isBizDay = isBusinessDay(currentDate, holidays);
|
|
231
|
+
|
|
232
|
+
// Apply transactions for this date
|
|
233
|
+
const dayTransactions = transactionEffects.get(dateStr) || [];
|
|
234
|
+
for (const tx of dayTransactions) {
|
|
235
|
+
if (tx.amount > 0) {
|
|
236
|
+
// Deposit - track for delayed interest
|
|
237
|
+
depositTracker.push({
|
|
238
|
+
amount: tx.amount,
|
|
239
|
+
interestStartDate: tx.interestStartDate,
|
|
240
|
+
});
|
|
241
|
+
balance += tx.amount;
|
|
242
|
+
} else {
|
|
243
|
+
// Withdrawal - reduce from oldest deposits first (FIFO)
|
|
244
|
+
let remaining = Math.abs(tx.amount);
|
|
245
|
+
balance += tx.amount; // This subtracts since amount is negative
|
|
246
|
+
|
|
247
|
+
// Remove from deposit tracker (FIFO)
|
|
248
|
+
while (remaining > 0 && depositTracker.length > 0) {
|
|
249
|
+
const oldest = depositTracker[0]!;
|
|
250
|
+
if (oldest.amount <= remaining) {
|
|
251
|
+
remaining -= oldest.amount;
|
|
252
|
+
depositTracker.shift();
|
|
253
|
+
} else {
|
|
254
|
+
oldest.amount -= remaining;
|
|
255
|
+
remaining = 0;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Calculate interest-earning balance (only deposits past their start date)
|
|
262
|
+
let interestEarningBalance = 0;
|
|
263
|
+
for (const deposit of depositTracker) {
|
|
264
|
+
if (currentDate >= deposit.interestStartDate) {
|
|
265
|
+
interestEarningBalance += deposit.amount;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Get rate for this date
|
|
270
|
+
const rate = getRateForDate(currentDate, rates);
|
|
271
|
+
let dailyInterest = 0;
|
|
272
|
+
|
|
273
|
+
if (isBizDay && rate !== null && interestEarningBalance > 0) {
|
|
274
|
+
dailyInterest = calculateDailyInterest(interestEarningBalance, rate);
|
|
275
|
+
businessDaysCount++;
|
|
276
|
+
|
|
277
|
+
// Add interest to balance (compounding)
|
|
278
|
+
if (dailyInterest > 0) {
|
|
279
|
+
balance += dailyInterest;
|
|
280
|
+
// Add interest as immediately-earning deposit
|
|
281
|
+
depositTracker.push({
|
|
282
|
+
amount: dailyInterest,
|
|
283
|
+
interestStartDate: currentDate,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
nonBusinessDaysCount++;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
cumulativeInterest += dailyInterest;
|
|
291
|
+
|
|
292
|
+
dailyResults.push({
|
|
293
|
+
date: dateStr,
|
|
294
|
+
balance: interestEarningBalance,
|
|
295
|
+
rate: rate ?? 0,
|
|
296
|
+
dailyInterest,
|
|
297
|
+
isBusinessDay: isBizDay,
|
|
298
|
+
cumulativeInterest,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Move to next day
|
|
302
|
+
currentDate.setDate(currentDate.getDate() + 1);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
dailyResults,
|
|
307
|
+
totalInterest: cumulativeInterest,
|
|
308
|
+
finalBalance: balance,
|
|
309
|
+
businessDaysCount,
|
|
310
|
+
nonBusinessDaysCount,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Project future interest earnings.
|
|
316
|
+
* Assumes current balance remains constant (no new transactions).
|
|
317
|
+
*/
|
|
318
|
+
export function projectInterest(
|
|
319
|
+
params: InterestProjectionParams
|
|
320
|
+
): InterestProjection[] {
|
|
321
|
+
const {
|
|
322
|
+
currentBalance,
|
|
323
|
+
currentRate,
|
|
324
|
+
holidays: holidayArray,
|
|
325
|
+
days,
|
|
326
|
+
startDate,
|
|
327
|
+
} = params;
|
|
328
|
+
|
|
329
|
+
const holidays = new Set<string>(holidayArray);
|
|
330
|
+
const projections: InterestProjection[] = [];
|
|
331
|
+
|
|
332
|
+
const currentDate = startDate ? parseDateString(startDate) : new Date();
|
|
333
|
+
let projectedBalance = currentBalance;
|
|
334
|
+
let cumulativeInterest = 0;
|
|
335
|
+
|
|
336
|
+
for (let i = 0; i < days; i++) {
|
|
337
|
+
const dateStr = formatDateString(currentDate);
|
|
338
|
+
const isBizDay = isBusinessDay(currentDate, holidays);
|
|
339
|
+
let dailyInterest = 0;
|
|
340
|
+
|
|
341
|
+
if (isBizDay && projectedBalance > 0 && currentRate > 0) {
|
|
342
|
+
dailyInterest = calculateDailyInterest(projectedBalance, currentRate);
|
|
343
|
+
projectedBalance += dailyInterest;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
cumulativeInterest += dailyInterest;
|
|
347
|
+
|
|
348
|
+
projections.push({
|
|
349
|
+
date: dateStr,
|
|
350
|
+
projectedBalance,
|
|
351
|
+
projectedDailyInterest: dailyInterest,
|
|
352
|
+
projectedCumulativeInterest: cumulativeInterest,
|
|
353
|
+
isBusinessDay: isBizDay,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
currentDate.setDate(currentDate.getDate() + 1);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return projections;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Find pending deposits that haven't started earning interest yet.
|
|
364
|
+
*/
|
|
365
|
+
export function findPendingDeposits(
|
|
366
|
+
transactions: Array<{ date: string; amount: number }>,
|
|
367
|
+
holidays: Set<string>,
|
|
368
|
+
today: Date = new Date()
|
|
369
|
+
): PendingDepositInfo[] {
|
|
370
|
+
const pending: PendingDepositInfo[] = [];
|
|
371
|
+
|
|
372
|
+
for (const tx of transactions) {
|
|
373
|
+
if (tx.amount <= 0) continue;
|
|
374
|
+
|
|
375
|
+
const depositDate = parseDateString(tx.date);
|
|
376
|
+
const interestStartDate = getInterestStartDate(depositDate, holidays);
|
|
377
|
+
const daysUntil = getDaysUntilInterestStarts(depositDate, holidays, today);
|
|
378
|
+
|
|
379
|
+
if (daysUntil > 0) {
|
|
380
|
+
pending.push({
|
|
381
|
+
depositDate: tx.date,
|
|
382
|
+
amount: tx.amount,
|
|
383
|
+
interestStartDate: formatDateString(interestStartDate),
|
|
384
|
+
daysUntilInterest: daysUntil,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return pending;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Calculate estimated monthly interest based on current balance and rate.
|
|
394
|
+
* Uses average business days per month (approximately 22).
|
|
395
|
+
*/
|
|
396
|
+
export function estimateMonthlyInterest(
|
|
397
|
+
balance: number,
|
|
398
|
+
annualRate: number
|
|
399
|
+
): number {
|
|
400
|
+
const dailyInterest = calculateDailyInterest(balance, annualRate);
|
|
401
|
+
const avgBusinessDaysPerMonth = 22;
|
|
402
|
+
return dailyInterest * avgBusinessDaysPerMonth;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Calculate estimated yearly interest based on current balance and rate.
|
|
407
|
+
* Uses average business days per year (approximately 260).
|
|
408
|
+
*/
|
|
409
|
+
export function estimateYearlyInterest(
|
|
410
|
+
balance: number,
|
|
411
|
+
annualRate: number
|
|
412
|
+
): number {
|
|
413
|
+
const dailyInterest = calculateDailyInterest(balance, annualRate);
|
|
414
|
+
const avgBusinessDaysPerYear = 260;
|
|
415
|
+
return dailyInterest * avgBusinessDaysPerYear;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Get the date range for month-to-date calculations.
|
|
420
|
+
*/
|
|
421
|
+
export function getMonthToDateRange(today: Date = new Date()): {
|
|
422
|
+
fromDate: string;
|
|
423
|
+
toDate: string;
|
|
424
|
+
} {
|
|
425
|
+
const year = today.getFullYear();
|
|
426
|
+
const month = today.getMonth();
|
|
427
|
+
const firstOfMonth = new Date(year, month, 1);
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
fromDate: formatDateString(firstOfMonth),
|
|
431
|
+
toDate: formatDateString(today),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Get the date range for year-to-date calculations.
|
|
437
|
+
*/
|
|
438
|
+
export function getYearToDateRange(today: Date = new Date()): {
|
|
439
|
+
fromDate: string;
|
|
440
|
+
toDate: string;
|
|
441
|
+
} {
|
|
442
|
+
const year = today.getFullYear();
|
|
443
|
+
const firstOfYear = new Date(year, 0, 1);
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
fromDate: formatDateString(firstOfYear),
|
|
447
|
+
toDate: formatDateString(today),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Convert holidays array to Set for efficient lookup.
|
|
453
|
+
*/
|
|
454
|
+
export function holidaysToSet(holidays: string[]): Set<string> {
|
|
455
|
+
return new Set(holidays);
|
|
456
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interest Transaction Detector
|
|
3
|
+
*
|
|
4
|
+
* Automatically detects interest transactions from Momo/ZaloPay
|
|
5
|
+
* based on description patterns and expected amounts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DetectedInterestTransaction } from '@tuturuuu/types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Patterns to match interest transaction descriptions in Vietnamese and English
|
|
12
|
+
*/
|
|
13
|
+
const INTEREST_DESCRIPTION_PATTERNS: RegExp[] = [
|
|
14
|
+
// Vietnamese patterns
|
|
15
|
+
/lãi\s*suất/i,
|
|
16
|
+
/lãi\s*hàng\s*ngày/i,
|
|
17
|
+
/tiền\s*lãi/i,
|
|
18
|
+
/lãi\s*tiết\s*kiệm/i,
|
|
19
|
+
/sinh\s*lời/i,
|
|
20
|
+
/lợi\s*nhuận/i,
|
|
21
|
+
// English patterns
|
|
22
|
+
/interest/i,
|
|
23
|
+
/daily\s*interest/i,
|
|
24
|
+
/interest\s*earned/i,
|
|
25
|
+
/interest\s*payment/i,
|
|
26
|
+
// Provider-specific patterns
|
|
27
|
+
/momo\s*(interest|reward|lãi)/i,
|
|
28
|
+
/zalopay\s*(interest|reward|lãi)/i,
|
|
29
|
+
/ví\s*(momo|zalopay).*lãi/i,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Transaction input for detection
|
|
34
|
+
*/
|
|
35
|
+
export interface TransactionForDetection {
|
|
36
|
+
id: string;
|
|
37
|
+
amount: number;
|
|
38
|
+
description: string | null;
|
|
39
|
+
date: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Detect if a single transaction is likely an interest payment
|
|
44
|
+
*
|
|
45
|
+
* @param transaction - The transaction to analyze
|
|
46
|
+
* @param expectedDailyInterest - Expected daily interest (optional, improves detection)
|
|
47
|
+
* @param tolerance - Amount matching tolerance (default 10%)
|
|
48
|
+
* @returns DetectedInterestTransaction if detected, null otherwise
|
|
49
|
+
*/
|
|
50
|
+
export function detectInterestTransaction(
|
|
51
|
+
transaction: TransactionForDetection,
|
|
52
|
+
expectedDailyInterest?: number,
|
|
53
|
+
tolerance = 0.1
|
|
54
|
+
): DetectedInterestTransaction | null {
|
|
55
|
+
// Interest must be positive (income)
|
|
56
|
+
if (transaction.amount <= 0) return null;
|
|
57
|
+
|
|
58
|
+
const desc = transaction.description || '';
|
|
59
|
+
let confidence: 'high' | 'medium' | 'low' = 'low';
|
|
60
|
+
const matchReasons: string[] = [];
|
|
61
|
+
|
|
62
|
+
// Check description patterns
|
|
63
|
+
const descriptionMatch = INTEREST_DESCRIPTION_PATTERNS.some((pattern) =>
|
|
64
|
+
pattern.test(desc)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (descriptionMatch) {
|
|
68
|
+
confidence = 'high';
|
|
69
|
+
matchReasons.push('Description matches interest pattern');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check amount if expected daily interest is known
|
|
73
|
+
if (expectedDailyInterest && expectedDailyInterest > 0) {
|
|
74
|
+
const amountDiff =
|
|
75
|
+
Math.abs(transaction.amount - expectedDailyInterest) /
|
|
76
|
+
expectedDailyInterest;
|
|
77
|
+
if (amountDiff <= tolerance) {
|
|
78
|
+
// Amount matches expected - boost confidence
|
|
79
|
+
if (descriptionMatch) {
|
|
80
|
+
confidence = 'high';
|
|
81
|
+
} else {
|
|
82
|
+
confidence = 'medium';
|
|
83
|
+
}
|
|
84
|
+
matchReasons.push('Amount matches expected daily interest');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Only return if we have at least description match or expected amount match
|
|
89
|
+
// Small amounts alone are not reliable indicators (too many false positives)
|
|
90
|
+
if (matchReasons.length === 0) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
transactionId: transaction.id,
|
|
96
|
+
date: transaction.date,
|
|
97
|
+
amount: transaction.amount,
|
|
98
|
+
description: desc,
|
|
99
|
+
confidence,
|
|
100
|
+
matchReason: matchReasons.join('; '),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Scan multiple transactions and detect all interest payments
|
|
106
|
+
*
|
|
107
|
+
* @param transactions - Array of transactions to scan
|
|
108
|
+
* @param expectedDailyInterest - Expected daily interest (optional)
|
|
109
|
+
* @returns Array of detected interest transactions, sorted by date descending
|
|
110
|
+
*/
|
|
111
|
+
export function detectInterestTransactions(
|
|
112
|
+
transactions: TransactionForDetection[],
|
|
113
|
+
expectedDailyInterest?: number
|
|
114
|
+
): DetectedInterestTransaction[] {
|
|
115
|
+
return transactions
|
|
116
|
+
.map((t) => detectInterestTransaction(t, expectedDailyInterest))
|
|
117
|
+
.filter((t): t is DetectedInterestTransaction => t !== null)
|
|
118
|
+
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Summarize detection results
|
|
123
|
+
*
|
|
124
|
+
* @param detected - Array of detected transactions
|
|
125
|
+
* @returns Summary with counts by confidence level
|
|
126
|
+
*/
|
|
127
|
+
export function summarizeDetectionResults(
|
|
128
|
+
detected: DetectedInterestTransaction[]
|
|
129
|
+
): {
|
|
130
|
+
totalAmount: number;
|
|
131
|
+
highConfidence: number;
|
|
132
|
+
mediumConfidence: number;
|
|
133
|
+
lowConfidence: number;
|
|
134
|
+
} {
|
|
135
|
+
return {
|
|
136
|
+
totalAmount: detected.reduce((sum, t) => sum + t.amount, 0),
|
|
137
|
+
highConfidence: detected.filter((t) => t.confidence === 'high').length,
|
|
138
|
+
mediumConfidence: detected.filter((t) => t.confidence === 'medium').length,
|
|
139
|
+
lowConfidence: detected.filter((t) => t.confidence === 'low').length,
|
|
140
|
+
};
|
|
141
|
+
}
|