@tuturuuu/utils 0.0.3 → 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.
Files changed (186) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/biome.json +5 -0
  3. package/jsr.json +8 -8
  4. package/package.json +63 -32
  5. package/src/__tests__/ai-temp-auth.test.ts +309 -0
  6. package/src/__tests__/api-proxy-guard.test.ts +1451 -0
  7. package/src/__tests__/app-url.test.ts +270 -0
  8. package/src/__tests__/avatar-url.test.ts +97 -0
  9. package/src/__tests__/color-helper.test.ts +179 -0
  10. package/src/__tests__/constants.test.ts +351 -0
  11. package/src/__tests__/crypto.test.ts +107 -0
  12. package/src/__tests__/date-helper.test.ts +408 -0
  13. package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
  14. package/src/__tests__/format.test.ts +317 -0
  15. package/src/__tests__/html-sanitizer.test.ts +360 -0
  16. package/src/__tests__/interest-calculator.test.ts +336 -0
  17. package/src/__tests__/interest-detector.test.ts +222 -0
  18. package/src/__tests__/label-colors.test.ts +241 -0
  19. package/src/__tests__/name-helper.test.ts +158 -0
  20. package/src/__tests__/node-diff.test.ts +576 -0
  21. package/src/__tests__/notification-service.test.ts +210 -0
  22. package/src/__tests__/onboarding-helper.test.ts +331 -0
  23. package/src/__tests__/path-helper.test.ts +152 -0
  24. package/src/__tests__/permissions.test.tsx +81 -0
  25. package/src/__tests__/request-emoji-limit.test.ts +172 -0
  26. package/src/__tests__/search-helper.test.ts +51 -0
  27. package/src/__tests__/storage-display-name.test.ts +37 -0
  28. package/src/__tests__/storage-path.test.ts +238 -0
  29. package/src/__tests__/tag-utils.test.ts +205 -0
  30. package/src/__tests__/task-description-yjs-state.test.ts +581 -0
  31. package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
  32. package/src/__tests__/task-helper-create-task.test.ts +129 -0
  33. package/src/__tests__/task-helpers.test.ts +464 -0
  34. package/src/__tests__/task-overrides.test.ts +305 -0
  35. package/src/__tests__/task-reorder-cache.test.ts +74 -0
  36. package/src/__tests__/task-sort-keys.test.ts +36 -0
  37. package/src/__tests__/task-transformers.test.ts +62 -0
  38. package/src/__tests__/text-helper.test.ts +776 -0
  39. package/src/__tests__/time-helper.test.ts +70 -0
  40. package/src/__tests__/time-tracker-period.test.ts +55 -0
  41. package/src/__tests__/timezone.test.ts +117 -0
  42. package/src/__tests__/upstash-rest.test.ts +77 -0
  43. package/src/__tests__/uuid-helper.test.ts +133 -0
  44. package/src/__tests__/workspace-helper.test.ts +859 -0
  45. package/src/__tests__/workspace-limits.test.ts +255 -0
  46. package/src/__tests__/yjs-helper.test.ts +581 -0
  47. package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
  48. package/src/abuse-protection/__tests__/edge.test.ts +136 -0
  49. package/src/abuse-protection/__tests__/index.test.ts +562 -0
  50. package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
  51. package/src/abuse-protection/backend-rate-limit.ts +44 -0
  52. package/src/abuse-protection/constants.ts +117 -0
  53. package/src/abuse-protection/edge.ts +223 -0
  54. package/src/abuse-protection/index.ts +1545 -0
  55. package/src/abuse-protection/reputation.ts +587 -0
  56. package/src/abuse-protection/types.ts +97 -0
  57. package/src/abuse-protection/user-agent.ts +124 -0
  58. package/src/abuse-protection/user-suspension.ts +231 -0
  59. package/src/ai-temp-auth.ts +315 -0
  60. package/src/api-proxy-guard.ts +965 -0
  61. package/src/app-url.ts +96 -0
  62. package/src/avatar-url.ts +64 -0
  63. package/src/break-duration.ts +84 -0
  64. package/src/calendar-auth-token.test.ts +37 -0
  65. package/src/calendar-auth-token.ts +19 -0
  66. package/src/calendar-sync-coordination.md +197 -0
  67. package/src/calendar-utils.test.ts +169 -0
  68. package/src/calendar-utils.ts +91 -0
  69. package/src/color-helper.ts +110 -0
  70. package/src/common/nextjs.tsx +99 -0
  71. package/src/common/scan.tsx +15 -0
  72. package/src/configs/reports.ts +160 -0
  73. package/src/constants.ts +85 -0
  74. package/src/crypto.ts +21 -0
  75. package/src/currencies.ts +97 -0
  76. package/src/date-helper.ts +313 -0
  77. package/src/editor/convert-to-task.ts +264 -0
  78. package/src/editor/index.ts +5 -0
  79. package/src/email/__tests__/client.test.ts +141 -0
  80. package/src/email/__tests__/validation.test.ts +46 -0
  81. package/src/email/client.ts +92 -0
  82. package/src/email/server.ts +128 -0
  83. package/src/email/validation.ts +11 -0
  84. package/src/encryption/__tests__/calendar-events.test.ts +411 -0
  85. package/src/encryption/__tests__/configuration.test.ts +114 -0
  86. package/src/encryption/__tests__/field-encryption.test.ts +232 -0
  87. package/src/encryption/__tests__/key-generation.test.ts +30 -0
  88. package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
  89. package/src/encryption/__tests__/test-helpers.ts +22 -0
  90. package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
  91. package/src/encryption/encryption-service.ts +343 -0
  92. package/src/encryption/index.ts +25 -0
  93. package/src/encryption/types.ts +57 -0
  94. package/src/exchange-rates.ts +49 -0
  95. package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
  96. package/src/feature-flags/core.ts +322 -0
  97. package/src/feature-flags/data.ts +16 -0
  98. package/src/feature-flags/default.ts +18 -0
  99. package/src/feature-flags/index.ts +7 -0
  100. package/src/feature-flags/requestable-features.ts +79 -0
  101. package/src/feature-flags/types.ts +4 -0
  102. package/src/fetcher.ts +2 -0
  103. package/src/finance/index.ts +4 -0
  104. package/src/finance/interest-calculator.ts +456 -0
  105. package/src/finance/interest-detector.ts +141 -0
  106. package/src/finance/transform-invoice-results.ts +219 -0
  107. package/src/finance/wallet-permissions.test.ts +169 -0
  108. package/src/finance/wallet-permissions.ts +82 -0
  109. package/src/format.ts +120 -1
  110. package/src/generated/platform-build-metadata.ts +11 -0
  111. package/src/hooks/use-platform.ts +64 -0
  112. package/src/html-sanitizer.ts +155 -0
  113. package/src/internal-domains.ts +497 -0
  114. package/src/keyboard-preset.ts +109 -0
  115. package/src/label-colors.ts +213 -0
  116. package/src/launchable-apps.test.ts +126 -0
  117. package/src/launchable-apps.ts +490 -0
  118. package/src/name-helper.ts +269 -0
  119. package/src/next-config.test.ts +234 -0
  120. package/src/next-config.ts +203 -0
  121. package/src/node-diff.ts +375 -0
  122. package/src/notification-service.ts +379 -0
  123. package/src/nova/scores/__tests__/calculate.test.ts +254 -0
  124. package/src/nova/scores/calculate.ts +132 -0
  125. package/src/nova/submissions/check-permission.ts +132 -0
  126. package/src/onboarding-helper.ts +213 -0
  127. package/src/path-helper.ts +93 -0
  128. package/src/permissions.tsx +1170 -0
  129. package/src/plan-helpers.test.ts +188 -0
  130. package/src/plan-helpers.ts +80 -0
  131. package/src/platform-release.test.ts +74 -0
  132. package/src/platform-release.ts +155 -0
  133. package/src/portless.ts +124 -0
  134. package/src/priority-styles.ts +42 -0
  135. package/src/request-emoji-limit.ts +335 -0
  136. package/src/search-helper.ts +18 -0
  137. package/src/search.test.ts +89 -0
  138. package/src/search.ts +355 -0
  139. package/src/storage-display-name.ts +30 -0
  140. package/src/storage-path.ts +147 -0
  141. package/src/tag-utils.ts +159 -0
  142. package/src/task/reorder.ts +245 -0
  143. package/src/task/transformers.ts +149 -0
  144. package/src/task-date-timezone.ts +133 -0
  145. package/src/task-description-content.ts +240 -0
  146. package/src/task-helper/board.ts +193 -0
  147. package/src/task-helper/bulk-actions.ts +564 -0
  148. package/src/task-helper/personal-external-staging.ts +21 -0
  149. package/src/task-helper/recycle-bin.ts +202 -0
  150. package/src/task-helper/relationships.ts +346 -0
  151. package/src/task-helper/shared.ts +109 -0
  152. package/src/task-helper/sort-keys.ts +337 -0
  153. package/src/task-helper/task-hooks-basic.ts +342 -0
  154. package/src/task-helper/task-hooks-move.ts +264 -0
  155. package/src/task-helper/task-operations.ts +278 -0
  156. package/src/task-helper.ts +12 -0
  157. package/src/task-helpers.ts +241 -0
  158. package/src/task-list-status.ts +62 -0
  159. package/src/task-overrides.ts +82 -0
  160. package/src/task-snapshot.ts +374 -0
  161. package/src/text-diff.ts +81 -0
  162. package/src/text-helper.ts +537 -0
  163. package/src/time-helper.ts +63 -0
  164. package/src/time-tracker-period.ts +73 -0
  165. package/src/timeblock-helper.ts +418 -0
  166. package/src/timezone.ts +190 -0
  167. package/src/timezones.json +1271 -0
  168. package/src/upstash-rest.ts +56 -0
  169. package/src/user-helper.ts +296 -0
  170. package/src/uuid-helper.ts +11 -0
  171. package/src/workspace-handle.ts +10 -0
  172. package/src/workspace-helper.ts +1408 -0
  173. package/src/workspace-limits.ts +68 -0
  174. package/src/yjs-helper.ts +217 -0
  175. package/src/yjs-task-description.ts +81 -0
  176. package/tsconfig.json +3 -5
  177. package/tsconfig.typecheck.json +33 -0
  178. package/vitest.config.ts +36 -0
  179. package/dist/index.d.ts +0 -8
  180. package/dist/index.js +0 -2
  181. package/dist/index.js.map +0 -1
  182. package/dist/index.mjs +0 -2
  183. package/dist/index.mjs.map +0 -1
  184. package/eslint.config.mjs +0 -20
  185. package/rollup.config.js +0 -41
  186. 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
+ }
@@ -0,0 +1,4 @@
1
+ import type { FEATURE_FLAGS } from './data';
2
+
3
+ export type FeatureFlag = keyof typeof FEATURE_FLAGS;
4
+ export type FeatureFlagMap = Record<FeatureFlag, boolean>;
package/src/fetcher.ts ADDED
@@ -0,0 +1,2 @@
1
+ export const fetcher = (...args: [RequestInfo, RequestInit?]) =>
2
+ fetch(...args).then((res) => res.json());
@@ -0,0 +1,4 @@
1
+ export * from './interest-calculator';
2
+ export * from './interest-detector';
3
+ export * from './transform-invoice-results';
4
+ export * from './wallet-permissions';
@@ -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
+ }