refactor(time): migrate runtime time handling to Temporal

This commit is contained in:
2026-03-09 07:18:09 +04:00
parent fa8fa7fe23
commit 29f6d788e7
25 changed files with 353 additions and 104 deletions

View File

@@ -1,6 +1,7 @@
import { describe, expect, mock, test } from 'bun:test' import { describe, expect, mock, test } from 'bun:test'
import type { AnonymousFeedbackService } from '@household/application' import type { AnonymousFeedbackService } from '@household/application'
import { instantFromIso, nowInstant, Temporal, type Instant } from '@household/domain'
import type { import type {
HouseholdConfigurationRepository, HouseholdConfigurationRepository,
TelegramPendingActionRepository TelegramPendingActionRepository
@@ -43,7 +44,7 @@ function anonUpdate(params: {
} }
function createPromptRepository(): TelegramPendingActionRepository { function createPromptRepository(): TelegramPendingActionRepository {
const store = new Map<string, { action: 'anonymous_feedback'; expiresAt: Date | null }>() const store = new Map<string, { action: 'anonymous_feedback'; expiresAt: Instant | null }>()
return { return {
async upsertPendingAction(input) { async upsertPendingAction(input) {
@@ -60,7 +61,7 @@ function createPromptRepository(): TelegramPendingActionRepository {
return null return null
} }
if (record.expiresAt && record.expiresAt.getTime() <= Date.now()) { if (record.expiresAt && Temporal.Instant.compare(record.expiresAt, nowInstant()) <= 0) {
store.delete(key) store.delete(key)
return null return null
} }
@@ -477,4 +478,68 @@ describe('registerAnonymousFeedback', () => {
expect(calls[1]?.method).toBe('answerCallbackQuery') expect(calls[1]?.method).toBe('answerCallbackQuery')
expect(calls[2]?.method).toBe('editMessageText') expect(calls[2]?.method).toBe('editMessageText')
}) })
test('includes the next allowed time in cooldown replies', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
bot.botInfo = {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot',
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: true,
allows_users_to_create_topics: false
}
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: 1,
type: 'private'
},
text: 'ok'
}
} as never
})
registerAnonymousFeedback({
bot,
anonymousFeedbackServiceForHousehold: () => ({
submit: mock(async () => ({
status: 'rejected' as const,
reason: 'cooldown' as const,
nextAllowedAt: nowInstant().add({ hours: 6 })
})),
markPosted: mock(async () => {}),
markFailed: mock(async () => {})
}),
householdConfigurationRepository: createHouseholdConfigurationRepository(),
promptRepository: createPromptRepository()
})
await bot.handleUpdate(
anonUpdate({
updateId: 1008,
chatType: 'private',
text: '/anon Please clean the kitchen tonight.'
}) as never
)
expect(calls).toHaveLength(1)
expect(calls[0]?.payload).toMatchObject({
text: 'Anonymous feedback cooldown is active. You can send the next message in 6 hours.'
})
})
}) })

View File

@@ -1,4 +1,5 @@
import type { AnonymousFeedbackService } from '@household/application' import type { AnonymousFeedbackService } from '@household/application'
import { Temporal, nowInstant, type Instant } from '@household/domain'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
import type { import type {
HouseholdConfigurationRepository, HouseholdConfigurationRepository,
@@ -43,7 +44,30 @@ function shouldKeepPrompt(reason: string): boolean {
return reason === 'too_short' || reason === 'too_long' || reason === 'blocklisted' return reason === 'too_short' || reason === 'too_long' || reason === 'blocklisted'
} }
function rejectionMessage(reason: string): string { function formatRetryDelay(now: Instant, nextAllowedAt: Instant): string {
if (Temporal.Instant.compare(nextAllowedAt, now) <= 0) {
return 'now'
}
const duration = now.until(nextAllowedAt, {
largestUnit: 'hour',
smallestUnit: 'minute',
roundingMode: 'ceil'
})
const days = Math.floor(duration.hours / 24)
const hours = duration.hours % 24
const parts = [
days > 0 ? `${days} day${days === 1 ? '' : 's'}` : null,
hours > 0 ? `${hours} hour${hours === 1 ? '' : 's'}` : null,
duration.minutes > 0 ? `${duration.minutes} minute${duration.minutes === 1 ? '' : 's'}` : null
].filter(Boolean)
return parts.length > 0 ? `in ${parts.join(' ')}` : 'in less than a minute'
}
function rejectionMessage(reason: string, nextAllowedAt?: Instant, now = nowInstant()): string {
switch (reason) { switch (reason) {
case 'not_member': case 'not_member':
return 'You are not a member of this household.' return 'You are not a member of this household.'
@@ -52,9 +76,13 @@ function rejectionMessage(reason: string): string {
case 'too_long': case 'too_long':
return 'Anonymous feedback is too long. Keep it under 500 characters.' return 'Anonymous feedback is too long. Keep it under 500 characters.'
case 'cooldown': case 'cooldown':
return 'Anonymous feedback cooldown is active. Try again later.' return nextAllowedAt
? `Anonymous feedback cooldown is active. You can send the next message ${formatRetryDelay(now, nextAllowedAt)}.`
: 'Anonymous feedback cooldown is active. Try again later.'
case 'daily_cap': case 'daily_cap':
return 'Daily anonymous feedback limit reached. Try again tomorrow.' return nextAllowedAt
? `Daily anonymous feedback limit reached. You can send the next message ${formatRetryDelay(now, nextAllowedAt)}.`
: 'Daily anonymous feedback limit reached. Try again tomorrow.'
case 'blocklisted': case 'blocklisted':
return 'Message rejected by moderation. Rewrite it in calmer, non-abusive language.' return 'Message rejected by moderation. Rewrite it in calmer, non-abusive language.'
default: default:
@@ -91,7 +119,7 @@ async function startPendingAnonymousFeedbackPrompt(
telegramChatId, telegramChatId,
action: ANONYMOUS_FEEDBACK_ACTION, action: ANONYMOUS_FEEDBACK_ACTION,
payload: {}, payload: {},
expiresAt: new Date(Date.now() + PENDING_ACTION_TTL_MS) expiresAt: nowInstant().add({ milliseconds: PENDING_ACTION_TTL_MS })
}) })
await ctx.reply('Send me the anonymous message in your next reply, or tap Cancel.', { await ctx.reply('Send me the anonymous message in your next reply, or tap Cancel.', {
@@ -175,10 +203,12 @@ async function submitAnonymousFeedback(options: {
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
} }
const rejectionText = rejectionMessage(result.reason, result.nextAllowedAt, nowInstant())
await options.ctx.reply( await options.ctx.reply(
shouldKeepPrompt(result.reason) shouldKeepPrompt(result.reason)
? `${rejectionMessage(result.reason)} Send a revised message, or tap Cancel.` ? `${rejectionText} Send a revised message, or tap Cancel.`
: rejectionMessage(result.reason), : rejectionText,
shouldKeepPrompt(result.reason) shouldKeepPrompt(result.reason)
? { ? {
reply_markup: cancelReplyMarkup() reply_markup: cancelReplyMarkup()

View File

@@ -4,6 +4,7 @@ import {
createFinanceCommandService, createFinanceCommandService,
createHouseholdOnboardingService createHouseholdOnboardingService
} from '@household/application' } from '@household/application'
import { instantFromIso } from '@household/domain'
import type { import type {
FinanceRepository, FinanceRepository,
HouseholdConfigurationRepository, HouseholdConfigurationRepository,
@@ -53,7 +54,7 @@ function repository(
amountMinor: 12000n, amountMinor: 12000n,
currency: 'USD', currency: 'USD',
createdByMemberId: member?.id ?? 'member-1', createdByMemberId: member?.id ?? 'member-1',
createdAt: new Date('2026-03-12T12:00:00.000Z') createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
} }
], ],
listParsedPurchasesForRange: async () => [ listParsedPurchasesForRange: async () => [
@@ -62,7 +63,7 @@ function repository(
payerMemberId: member?.id ?? 'member-1', payerMemberId: member?.id ?? 'member-1',
amountMinor: 3000n, amountMinor: 3000n,
description: 'Soap', description: 'Soap',
occurredAt: new Date('2026-03-12T11:00:00.000Z') occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
} }
], ],
replaceSettlementSnapshot: async () => {} replaceSettlementSnapshot: async () => {}

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { instantFromIso } from '@household/domain'
import { createTelegramBot } from './bot' import { createTelegramBot } from './bot'
import { import {
@@ -25,7 +26,7 @@ function candidate(overrides: Partial<PurchaseTopicCandidate> = {}): PurchaseTop
threadId: '777', threadId: '777',
senderTelegramUserId: '10002', senderTelegramUserId: '10002',
rawText: 'Bought toilet paper 30 gel', rawText: 'Bought toilet paper 30 gel',
messageSentAt: new Date('2026-03-05T00:00:00.000Z'), messageSentAt: instantFromIso('2026-03-05T00:00:00.000Z'),
...overrides ...overrides
} }
} }

View File

@@ -1,5 +1,5 @@
import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application' import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application'
import { Money } from '@household/domain' import { instantFromEpochSeconds, instantToDate, Money, type Instant } from '@household/domain'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import type { Bot, Context } from 'grammy' import type { Bot, Context } from 'grammy'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
@@ -24,7 +24,7 @@ export interface PurchaseTopicCandidate {
senderTelegramUserId: string senderTelegramUserId: string
senderDisplayName?: string senderDisplayName?: string
rawText: string rawText: string
messageSentAt: Date messageSentAt: Instant
} }
export interface PurchaseTopicRecord extends PurchaseTopicCandidate { export interface PurchaseTopicRecord extends PurchaseTopicCandidate {
@@ -170,7 +170,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
telegramMessageId: record.messageId, telegramMessageId: record.messageId,
telegramThreadId: record.threadId, telegramThreadId: record.threadId,
telegramUpdateId: String(record.updateId), telegramUpdateId: String(record.updateId),
messageSentAt: record.messageSentAt, messageSentAt: instantToDate(record.messageSentAt),
parsedAmountMinor: parsed?.amountMinor, parsedAmountMinor: parsed?.amountMinor,
parsedCurrency: parsed?.currency, parsedCurrency: parsed?.currency,
parsedItemDescription: parsed?.itemDescription, parsedItemDescription: parsed?.itemDescription,
@@ -286,7 +286,7 @@ function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null {
threadId: message.message_thread_id.toString(), threadId: message.message_thread_id.toString(),
senderTelegramUserId, senderTelegramUserId,
rawText: message.text, rawText: message.text,
messageSentAt: new Date(message.date * 1000) messageSentAt: instantFromEpochSeconds(message.date)
} }
if (senderDisplayName.length > 0) { if (senderDisplayName.length > 0) {

View File

@@ -1,5 +1,5 @@
import type { ReminderJobService } from '@household/application' import type { ReminderJobService } from '@household/application'
import { BillingPeriod } from '@household/domain' import { BillingPeriod, nowInstant } from '@household/domain'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
import { REMINDER_TYPES, type ReminderType } from '@household/ports' import { REMINDER_TYPES, type ReminderType } from '@household/ports'
@@ -27,11 +27,7 @@ function parseReminderType(raw: string): ReminderType | null {
} }
function currentPeriod(): string { function currentPeriod(): string {
const now = new Date() return BillingPeriod.fromInstant(nowInstant()).toString()
const year = now.getUTCFullYear()
const month = `${now.getUTCMonth() + 1}`.padStart(2, '0')
return `${year}-${month}`
} }
async function readBody(request: Request): Promise<ReminderJobRequestBody> { async function readBody(request: Request): Promise<ReminderJobRequestBody> {

View File

@@ -1,12 +1,14 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { instantFromIso, instantToEpochSeconds } from '@household/domain'
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
describe('verifyTelegramMiniAppInitData', () => { describe('verifyTelegramMiniAppInitData', () => {
test('verifies valid init data and extracts user payload', () => { test('verifies valid init data and extracts user payload', () => {
const now = new Date('2026-03-08T12:00:00.000Z') const now = instantFromIso('2026-03-08T12:00:00.000Z')
const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), { const initData = buildMiniAppInitData('test-bot-token', instantToEpochSeconds(now), {
id: 123456, id: 123456,
first_name: 'Stan', first_name: 'Stan',
username: 'stanislav' username: 'stanislav'
@@ -24,9 +26,9 @@ describe('verifyTelegramMiniAppInitData', () => {
}) })
test('rejects invalid hash', () => { test('rejects invalid hash', () => {
const now = new Date('2026-03-08T12:00:00.000Z') const now = instantFromIso('2026-03-08T12:00:00.000Z')
const params = new URLSearchParams( const params = new URLSearchParams(
buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), { buildMiniAppInitData('test-bot-token', instantToEpochSeconds(now), {
id: 123456, id: 123456,
first_name: 'Stan' first_name: 'Stan'
}) })
@@ -39,15 +41,11 @@ describe('verifyTelegramMiniAppInitData', () => {
}) })
test('rejects expired init data', () => { test('rejects expired init data', () => {
const now = new Date('2026-03-08T12:00:00.000Z') const now = instantFromIso('2026-03-08T12:00:00.000Z')
const initData = buildMiniAppInitData( const initData = buildMiniAppInitData('test-bot-token', instantToEpochSeconds(now) - 7200, {
'test-bot-token', id: 123456,
Math.floor(now.getTime() / 1000) - 7200, first_name: 'Stan'
{ })
id: 123456,
first_name: 'Stan'
}
)
const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now, 3600) const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now, 3600)
@@ -55,8 +53,8 @@ describe('verifyTelegramMiniAppInitData', () => {
}) })
test('rejects init data timestamps from the future', () => { test('rejects init data timestamps from the future', () => {
const now = new Date('2026-03-08T12:00:00.000Z') const now = instantFromIso('2026-03-08T12:00:00.000Z')
const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000) + 5, { const initData = buildMiniAppInitData('test-bot-token', instantToEpochSeconds(now) + 5, {
id: 123456, id: 123456,
first_name: 'Stan' first_name: 'Stan'
}) })

View File

@@ -1,5 +1,7 @@
import { createHmac, timingSafeEqual } from 'node:crypto' import { createHmac, timingSafeEqual } from 'node:crypto'
import { instantToEpochSeconds, nowInstant, type Instant } from '@household/domain'
interface TelegramUserPayload { interface TelegramUserPayload {
id: number id: number
first_name?: string first_name?: string
@@ -19,7 +21,7 @@ export interface VerifiedMiniAppUser {
export function verifyTelegramMiniAppInitData( export function verifyTelegramMiniAppInitData(
initData: string, initData: string,
botToken: string, botToken: string,
now = new Date(), now: Instant = nowInstant(),
maxAgeSeconds = 3600 maxAgeSeconds = 3600
): VerifiedMiniAppUser | null { ): VerifiedMiniAppUser | null {
const params = new URLSearchParams(initData) const params = new URLSearchParams(initData)
@@ -35,7 +37,7 @@ export function verifyTelegramMiniAppInitData(
} }
const authDateSeconds = Number(authDateRaw) const authDateSeconds = Number(authDateRaw)
const nowSeconds = Math.floor(now.getTime() / 1000) const nowSeconds = instantToEpochSeconds(now)
if (authDateSeconds > nowSeconds) { if (authDateSeconds > nowSeconds) {
return null return null
} }

View File

@@ -76,6 +76,9 @@
}, },
"packages/domain": { "packages/domain": {
"name": "@household/domain", "name": "@household/domain",
"dependencies": {
"@js-temporal/polyfill": "^0.5.1",
},
}, },
"packages/observability": { "packages/observability": {
"name": "@household/observability", "name": "@household/observability",
@@ -231,6 +234,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
"@nothing-but/utils": ["@nothing-but/utils@0.17.0", "", {}, "sha512-TuCHcHLOqDL0SnaAxACfuRHBNRgNJcNn9X0GiH5H3YSDBVquCr3qEIG3FOQAuMyZCbu9w8nk2CHhOsn7IvhIwQ=="], "@nothing-but/utils": ["@nothing-but/utils@0.17.0", "", {}, "sha512-TuCHcHLOqDL0SnaAxACfuRHBNRgNJcNn9X0GiH5H3YSDBVquCr3qEIG3FOQAuMyZCbu9w8nk2CHhOsn7IvhIwQ=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.51.0", "", { "os": "android", "cpu": "arm" }, "sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw=="], "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.51.0", "", { "os": "android", "cpu": "arm" }, "sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw=="],
@@ -533,6 +538,8 @@
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],

View File

@@ -0,0 +1,45 @@
# HOUSEBOT-074 Temporal Migration
## Goal
Move runtime time handling from ad-hoc `Date` usage to `Temporal` via `@js-temporal/polyfill`.
## Why
- Bun does not provide native `Temporal` yet, so the polyfill is the safe path.
- The bot has already had production failures caused by `Date` values crossing Bun/Postgres boundaries inconsistently.
- Time handling should be explicit and deterministic at adapter boundaries.
## Slice 1
- Add shared Temporal helpers in `@household/domain`
- Migrate anonymous feedback service and repository
- Migrate Telegram pending-action expiry handling
- Migrate mini app auth timestamp verification
## Boundary Rules
- Business/application code uses `Temporal.Instant` instead of `Date`
- Database adapters convert `Temporal.Instant` to SQL-compatible values on write
- Database adapters normalize string/`Date` timestamp values back to `Temporal.Instant` on read
- Telegram epoch timestamps remain numeric seconds at the API edge
## Non-goals
- Full repo-wide `Date` removal in one change
- Database schema changes
- Billing-period model replacement
## Follow-up Slices
- Finance command service and finance repository date handling
- Purchase ingestion timestamps
- Reminder job scheduling timestamps
- Test helpers that still construct `Date` values for untouched paths
## Acceptance
- No raw `Date` values cross the anonymous-feedback, pending-action, or mini app auth application boundaries
- `bun run typecheck`
- `bun run test`
- `bun run build`

View File

@@ -1,6 +1,7 @@
import { and, eq, inArray, sql } from 'drizzle-orm' import { and, eq, inArray, sql } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db' import { createDbClient, schema } from '@household/db'
import { instantFromDatabaseValue, instantToDate } from '@household/domain'
import type { import type {
AnonymousFeedbackModerationStatus, AnonymousFeedbackModerationStatus,
AnonymousFeedbackRepository AnonymousFeedbackRepository
@@ -49,11 +50,14 @@ export function createDbAnonymousFeedbackRepository(
}, },
async getRateLimitSnapshot(memberId, acceptedSince) { async getRateLimitSnapshot(memberId, acceptedSince) {
const acceptedSinceIso = acceptedSince.toISOString() const acceptedSinceIso = acceptedSince.toString()
const rows = await db const rows = await db
.select({ .select({
acceptedCountSince: sql<string>`count(*) filter (where ${schema.anonymousMessages.createdAt} >= ${acceptedSinceIso}::timestamptz)`, acceptedCountSince: sql<string>`count(*) filter (where ${schema.anonymousMessages.createdAt} >= ${acceptedSinceIso}::timestamptz)`,
earliestAcceptedAtSince: sql<
string | Date | null
>`min(${schema.anonymousMessages.createdAt}) filter (where ${schema.anonymousMessages.createdAt} >= ${acceptedSinceIso}::timestamptz)`,
lastAcceptedAt: sql<string | Date | null>`max(${schema.anonymousMessages.createdAt})` lastAcceptedAt: sql<string | Date | null>`max(${schema.anonymousMessages.createdAt})`
}) })
.from(schema.anonymousMessages) .from(schema.anonymousMessages)
@@ -65,16 +69,13 @@ export function createDbAnonymousFeedbackRepository(
) )
) )
const earliestAcceptedAtSinceRaw = rows[0]?.earliestAcceptedAtSince ?? null
const lastAcceptedAtRaw = rows[0]?.lastAcceptedAt ?? null const lastAcceptedAtRaw = rows[0]?.lastAcceptedAt ?? null
return { return {
acceptedCountSince: Number(rows[0]?.acceptedCountSince ?? '0'), acceptedCountSince: Number(rows[0]?.acceptedCountSince ?? '0'),
lastAcceptedAt: earliestAcceptedAtSince: instantFromDatabaseValue(earliestAcceptedAtSinceRaw),
lastAcceptedAtRaw instanceof Date lastAcceptedAt: instantFromDatabaseValue(lastAcceptedAtRaw)
? lastAcceptedAtRaw
: typeof lastAcceptedAtRaw === 'string'
? new Date(lastAcceptedAtRaw)
: null
} }
}, },
@@ -146,7 +147,7 @@ export function createDbAnonymousFeedbackRepository(
postedChatId: input.postedChatId, postedChatId: input.postedChatId,
postedThreadId: input.postedThreadId, postedThreadId: input.postedThreadId,
postedMessageId: input.postedMessageId, postedMessageId: input.postedMessageId,
postedAt: input.postedAt, postedAt: instantToDate(input.postedAt),
failureReason: null failureReason: null
}) })
.where(eq(schema.anonymousMessages.id, input.submissionId)) .where(eq(schema.anonymousMessages.id, input.submissionId))

View File

@@ -1,8 +1,13 @@
import { and, desc, eq, gte, isNotNull, isNull, lte, or, sql } from 'drizzle-orm' import { and, desc, eq, gte, isNotNull, isNull, lt, lte, or, sql } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db' import { createDbClient, schema } from '@household/db'
import type { FinanceRepository } from '@household/ports' import type { FinanceRepository } from '@household/ports'
import type { CurrencyCode } from '@household/domain' import {
instantFromDatabaseValue,
instantToDate,
nowInstant,
type CurrencyCode
} from '@household/domain'
function toCurrencyCode(raw: string): CurrencyCode { function toCurrencyCode(raw: string): CurrencyCode {
const normalized = raw.trim().toUpperCase() const normalized = raw.trim().toUpperCase()
@@ -171,7 +176,7 @@ export function createDbFinanceRepository(
await db await db
.update(schema.billingCycles) .update(schema.billingCycles)
.set({ .set({
closedAt closedAt: instantToDate(closedAt)
}) })
.where(eq(schema.billingCycles.id, cycleId)) .where(eq(schema.billingCycles.id, cycleId))
}, },
@@ -265,7 +270,8 @@ export function createDbFinanceRepository(
return rows.map((row) => ({ return rows.map((row) => ({
...row, ...row,
currency: toCurrencyCode(row.currency) currency: toCurrencyCode(row.currency),
createdAt: instantFromDatabaseValue(row.createdAt)!
})) }))
}, },
@@ -284,8 +290,8 @@ export function createDbFinanceRepository(
eq(schema.purchaseMessages.householdId, householdId), eq(schema.purchaseMessages.householdId, householdId),
isNotNull(schema.purchaseMessages.senderMemberId), isNotNull(schema.purchaseMessages.senderMemberId),
isNotNull(schema.purchaseMessages.parsedAmountMinor), isNotNull(schema.purchaseMessages.parsedAmountMinor),
gte(schema.purchaseMessages.messageSentAt, start), gte(schema.purchaseMessages.messageSentAt, instantToDate(start)),
lte(schema.purchaseMessages.messageSentAt, end) lt(schema.purchaseMessages.messageSentAt, instantToDate(end))
) )
) )
@@ -294,7 +300,7 @@ export function createDbFinanceRepository(
payerMemberId: row.payerMemberId!, payerMemberId: row.payerMemberId!,
amountMinor: row.amountMinor!, amountMinor: row.amountMinor!,
description: row.description, description: row.description,
occurredAt: row.occurredAt occurredAt: instantFromDatabaseValue(row.occurredAt)
})) }))
}, },
@@ -316,7 +322,7 @@ export function createDbFinanceRepository(
inputHash: snapshot.inputHash, inputHash: snapshot.inputHash,
totalDueMinor: snapshot.totalDueMinor, totalDueMinor: snapshot.totalDueMinor,
currency: snapshot.currency, currency: snapshot.currency,
computedAt: new Date(), computedAt: instantToDate(nowInstant()),
metadata: snapshot.metadata metadata: snapshot.metadata
} }
}) })

View File

@@ -1,6 +1,7 @@
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db' import { createDbClient, schema } from '@household/db'
import { instantToDate, nowInstant } from '@household/domain'
import { import {
HOUSEHOLD_TOPIC_ROLES, HOUSEHOLD_TOPIC_ROLES,
type HouseholdConfigurationRepository, type HouseholdConfigurationRepository,
@@ -138,7 +139,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
.set({ .set({
telegramChatType: input.telegramChatType, telegramChatType: input.telegramChatType,
title: nextTitle, title: nextTitle,
updatedAt: new Date() updatedAt: instantToDate(nowInstant())
}) })
.where(eq(schema.householdTelegramChats.telegramChatId, input.telegramChatId)) .where(eq(schema.householdTelegramChats.telegramChatId, input.telegramChatId))
@@ -256,7 +257,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
set: { set: {
telegramThreadId: input.telegramThreadId, telegramThreadId: input.telegramThreadId,
topicName: input.topicName?.trim() || null, topicName: input.topicName?.trim() || null,
updatedAt: new Date() updatedAt: instantToDate(nowInstant())
} }
}) })
.returning({ .returning({
@@ -348,7 +349,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
set: { set: {
token: input.token, token: input.token,
createdByTelegramUserId: input.createdByTelegramUserId ?? null, createdByTelegramUserId: input.createdByTelegramUserId ?? null,
updatedAt: new Date() updatedAt: instantToDate(nowInstant())
} }
}) })
.returning({ .returning({
@@ -448,7 +449,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
displayName: input.displayName, displayName: input.displayName,
username: input.username?.trim() || null, username: input.username?.trim() || null,
languageCode: input.languageCode?.trim() || null, languageCode: input.languageCode?.trim() || null,
updatedAt: new Date() updatedAt: instantToDate(nowInstant())
} }
}) })
.returning({ .returning({

View File

@@ -1,6 +1,7 @@
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db' import { createDbClient, schema } from '@household/db'
import { instantFromDatabaseValue, instantToDate, nowInstant, Temporal } from '@household/domain'
import type { import type {
TelegramPendingActionRecord, TelegramPendingActionRecord,
TelegramPendingActionRepository, TelegramPendingActionRepository,
@@ -20,7 +21,7 @@ function mapPendingAction(row: {
telegramChatId: string telegramChatId: string
action: string action: string
payload: unknown payload: unknown
expiresAt: Date | null expiresAt: Date | string | null
}): TelegramPendingActionRecord { }): TelegramPendingActionRecord {
return { return {
telegramUserId: row.telegramUserId, telegramUserId: row.telegramUserId,
@@ -30,7 +31,7 @@ function mapPendingAction(row: {
row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload) row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload)
? (row.payload as Record<string, unknown>) ? (row.payload as Record<string, unknown>)
: {}, : {},
expiresAt: row.expiresAt expiresAt: instantFromDatabaseValue(row.expiresAt)
} }
} }
@@ -52,8 +53,8 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): {
telegramChatId: input.telegramChatId, telegramChatId: input.telegramChatId,
action: input.action, action: input.action,
payload: input.payload, payload: input.payload,
expiresAt: input.expiresAt, expiresAt: input.expiresAt ? instantToDate(input.expiresAt) : null,
updatedAt: new Date() updatedAt: instantToDate(nowInstant())
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [ target: [
@@ -63,8 +64,8 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): {
set: { set: {
action: input.action, action: input.action,
payload: input.payload, payload: input.payload,
expiresAt: input.expiresAt, expiresAt: input.expiresAt ? instantToDate(input.expiresAt) : null,
updatedAt: new Date() updatedAt: instantToDate(nowInstant())
} }
}) })
.returning({ .returning({
@@ -84,7 +85,7 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): {
}, },
async getPendingAction(telegramChatId, telegramUserId) { async getPendingAction(telegramChatId, telegramUserId) {
const now = new Date() const now = nowInstant()
const rows = await db const rows = await db
.select({ .select({
telegramUserId: schema.telegramPendingActions.telegramUserId, telegramUserId: schema.telegramPendingActions.telegramUserId,
@@ -107,7 +108,8 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): {
return null return null
} }
if (row.expiresAt && row.expiresAt.getTime() <= now.getTime()) { const expiresAt = instantFromDatabaseValue(row.expiresAt)
if (expiresAt && Temporal.Instant.compare(expiresAt, now) <= 0) {
await db await db
.delete(schema.telegramPendingActions) .delete(schema.telegramPendingActions)
.where( .where(
@@ -120,7 +122,16 @@ export function createDbTelegramPendingActionRepository(databaseUrl: string): {
return null return null
} }
return mapPendingAction(row) return {
telegramUserId: row.telegramUserId,
telegramChatId: row.telegramChatId,
action: parsePendingActionType(row.action),
payload:
row.payload && typeof row.payload === 'object' && !Array.isArray(row.payload)
? (row.payload as Record<string, unknown>)
: {},
expiresAt
}
}, },
async clearPendingAction(telegramChatId, telegramUserId) { async clearPendingAction(telegramChatId, telegramUserId) {

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { instantFromIso, type Instant } from '@household/domain'
import type { import type {
AnonymousFeedbackMemberRecord, AnonymousFeedbackMemberRecord,
AnonymousFeedbackRepository, AnonymousFeedbackRepository,
@@ -16,7 +17,8 @@ class AnonymousFeedbackRepositoryStub implements AnonymousFeedbackRepository {
} }
acceptedCountSince = 0 acceptedCountSince = 0
lastAcceptedAt: Date | null = null earliestAcceptedAtSince: Instant | null = null
lastAcceptedAt: Instant | null = null
duplicate = false duplicate = false
created: Array<{ created: Array<{
rawText: string rawText: string
@@ -34,6 +36,7 @@ class AnonymousFeedbackRepositoryStub implements AnonymousFeedbackRepository {
async getRateLimitSnapshot() { async getRateLimitSnapshot() {
return { return {
acceptedCountSince: this.acceptedCountSince, acceptedCountSince: this.acceptedCountSince,
earliestAcceptedAtSince: this.earliestAcceptedAtSince,
lastAcceptedAt: this.lastAcceptedAt lastAcceptedAt: this.lastAcceptedAt
} }
} }
@@ -69,7 +72,7 @@ class AnonymousFeedbackRepositoryStub implements AnonymousFeedbackRepository {
postedChatId: string postedChatId: string
postedThreadId: string postedThreadId: string
postedMessageId: string postedMessageId: string
postedAt: Date postedAt: Instant
}) { }) {
this.posted.push({ this.posted.push({
submissionId: input.submissionId, submissionId: input.submissionId,
@@ -94,7 +97,7 @@ describe('createAnonymousFeedbackService', () => {
telegramChatId: 'chat-1', telegramChatId: 'chat-1',
telegramMessageId: 'message-1', telegramMessageId: 'message-1',
telegramUpdateId: 'update-1', telegramUpdateId: 'update-1',
now: new Date('2026-03-08T12:00:00.000Z') now: instantFromIso('2026-03-08T12:00:00.000Z')
}) })
expect(result).toEqual({ expect(result).toEqual({
@@ -154,7 +157,7 @@ describe('createAnonymousFeedbackService', () => {
const repository = new AnonymousFeedbackRepositoryStub() const repository = new AnonymousFeedbackRepositoryStub()
const service = createAnonymousFeedbackService(repository) const service = createAnonymousFeedbackService(repository)
repository.lastAcceptedAt = new Date('2026-03-08T09:00:00.000Z') repository.lastAcceptedAt = instantFromIso('2026-03-08T09:00:00.000Z')
const cooldownResult = await service.submit({ const cooldownResult = await service.submit({
telegramUserId: '123', telegramUserId: '123',
@@ -162,15 +165,17 @@ describe('createAnonymousFeedbackService', () => {
telegramChatId: 'chat-1', telegramChatId: 'chat-1',
telegramMessageId: 'message-1', telegramMessageId: 'message-1',
telegramUpdateId: 'update-1', telegramUpdateId: 'update-1',
now: new Date('2026-03-08T12:00:00.000Z') now: instantFromIso('2026-03-08T12:00:00.000Z')
}) })
expect(cooldownResult).toEqual({ expect(cooldownResult).toEqual({
status: 'rejected', status: 'rejected',
reason: 'cooldown' reason: 'cooldown',
nextAllowedAt: instantFromIso('2026-03-08T15:00:00.000Z')
}) })
repository.lastAcceptedAt = new Date('2026-03-07T00:00:00.000Z') repository.earliestAcceptedAtSince = instantFromIso('2026-03-07T18:00:00.000Z')
repository.lastAcceptedAt = instantFromIso('2026-03-07T23:00:00.000Z')
repository.acceptedCountSince = 3 repository.acceptedCountSince = 3
const dailyCapResult = await service.submit({ const dailyCapResult = await service.submit({
@@ -179,12 +184,13 @@ describe('createAnonymousFeedbackService', () => {
telegramChatId: 'chat-1', telegramChatId: 'chat-1',
telegramMessageId: 'message-2', telegramMessageId: 'message-2',
telegramUpdateId: 'update-2', telegramUpdateId: 'update-2',
now: new Date('2026-03-08T12:00:00.000Z') now: instantFromIso('2026-03-08T12:00:00.000Z')
}) })
expect(dailyCapResult).toEqual({ expect(dailyCapResult).toEqual({
status: 'rejected', status: 'rejected',
reason: 'daily_cap' reason: 'daily_cap',
nextAllowedAt: instantFromIso('2026-03-08T18:00:00.000Z')
}) })
}) })

View File

@@ -2,6 +2,7 @@ import type {
AnonymousFeedbackRejectionReason, AnonymousFeedbackRejectionReason,
AnonymousFeedbackRepository AnonymousFeedbackRepository
} from '@household/ports' } from '@household/ports'
import { nowInstant, type Instant, Temporal } from '@household/domain'
const MIN_MESSAGE_LENGTH = 12 const MIN_MESSAGE_LENGTH = 12
const MAX_MESSAGE_LENGTH = 500 const MAX_MESSAGE_LENGTH = 500
@@ -46,6 +47,7 @@ export type AnonymousFeedbackSubmitResult =
status: 'rejected' status: 'rejected'
reason: AnonymousFeedbackRejectionReason reason: AnonymousFeedbackRejectionReason
detail?: string detail?: string
nextAllowedAt?: Instant
} }
export interface AnonymousFeedbackService { export interface AnonymousFeedbackService {
@@ -55,14 +57,14 @@ export interface AnonymousFeedbackService {
telegramChatId: string telegramChatId: string
telegramMessageId: string telegramMessageId: string
telegramUpdateId: string telegramUpdateId: string
now?: Date now?: Instant
}): Promise<AnonymousFeedbackSubmitResult> }): Promise<AnonymousFeedbackSubmitResult>
markPosted(input: { markPosted(input: {
submissionId: string submissionId: string
postedChatId: string postedChatId: string
postedThreadId: string postedThreadId: string
postedMessageId: string postedMessageId: string
postedAt?: Date postedAt?: Instant
}): Promise<void> }): Promise<void>
markFailed(submissionId: string, failureReason: string): Promise<void> markFailed(submissionId: string, failureReason: string): Promise<void>
} }
@@ -74,6 +76,7 @@ async function rejectSubmission(
rawText: string rawText: string
reason: AnonymousFeedbackRejectionReason reason: AnonymousFeedbackRejectionReason
detail?: string detail?: string
nextAllowedAt?: Instant
telegramChatId: string telegramChatId: string
telegramMessageId: string telegramMessageId: string
telegramUpdateId: string telegramUpdateId: string
@@ -100,6 +103,7 @@ async function rejectSubmission(
return { return {
status: 'rejected', status: 'rejected',
reason: input.reason, reason: input.reason,
...(input.nextAllowedAt ? { nextAllowedAt: input.nextAllowedAt } : {}),
...(input.detail ? { detail: input.detail } : {}) ...(input.detail ? { detail: input.detail } : {})
} }
} }
@@ -153,14 +157,18 @@ export function createAnonymousFeedbackService(
}) })
} }
const now = input.now ?? new Date() const now = input.now ?? nowInstant()
const acceptedSince = new Date(now.getTime() - 24 * 60 * 60 * 1000) const acceptedSince = now.subtract({ hours: 24 })
const rateLimit = await repository.getRateLimitSnapshot(member.id, acceptedSince) const rateLimit = await repository.getRateLimitSnapshot(member.id, acceptedSince)
if (rateLimit.acceptedCountSince >= DAILY_CAP) { if (rateLimit.acceptedCountSince >= DAILY_CAP) {
const nextAllowedAt =
rateLimit.earliestAcceptedAtSince?.add({ hours: 24 }) ?? now.add({ hours: 24 })
return rejectSubmission(repository, { return rejectSubmission(repository, {
memberId: member.id, memberId: member.id,
rawText: input.rawText, rawText: input.rawText,
reason: 'daily_cap', reason: 'daily_cap',
nextAllowedAt,
telegramChatId: input.telegramChatId, telegramChatId: input.telegramChatId,
telegramMessageId: input.telegramMessageId, telegramMessageId: input.telegramMessageId,
telegramUpdateId: input.telegramUpdateId telegramUpdateId: input.telegramUpdateId
@@ -168,12 +176,13 @@ export function createAnonymousFeedbackService(
} }
if (rateLimit.lastAcceptedAt) { if (rateLimit.lastAcceptedAt) {
const cooldownBoundary = now.getTime() - COOLDOWN_HOURS * 60 * 60 * 1000 const cooldownBoundary = now.subtract({ hours: COOLDOWN_HOURS })
if (rateLimit.lastAcceptedAt.getTime() > cooldownBoundary) { if (Temporal.Instant.compare(rateLimit.lastAcceptedAt, cooldownBoundary) > 0) {
return rejectSubmission(repository, { return rejectSubmission(repository, {
memberId: member.id, memberId: member.id,
rawText: input.rawText, rawText: input.rawText,
reason: 'cooldown', reason: 'cooldown',
nextAllowedAt: rateLimit.lastAcceptedAt.add({ hours: COOLDOWN_HOURS }),
telegramChatId: input.telegramChatId, telegramChatId: input.telegramChatId,
telegramMessageId: input.telegramMessageId, telegramMessageId: input.telegramMessageId,
telegramUpdateId: input.telegramUpdateId telegramUpdateId: input.telegramUpdateId
@@ -212,7 +221,7 @@ export function createAnonymousFeedbackService(
postedChatId: input.postedChatId, postedChatId: input.postedChatId,
postedThreadId: input.postedThreadId, postedThreadId: input.postedThreadId,
postedMessageId: input.postedMessageId, postedMessageId: input.postedMessageId,
postedAt: input.postedAt ?? new Date() postedAt: input.postedAt ?? nowInstant()
}) })
}, },

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { instantFromIso, type Instant } from '@household/domain'
import type { import type {
FinanceCycleRecord, FinanceCycleRecord,
FinanceMemberRecord, FinanceMemberRecord,
@@ -26,7 +27,7 @@ class FinanceRepositoryStub implements FinanceRepository {
amountMinor: bigint amountMinor: bigint
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
createdByMemberId: string | null createdByMemberId: string | null
createdAt: Date createdAt: Instant
}[] = [] }[] = []
lastSavedRentRule: { lastSavedRentRule: {
@@ -180,7 +181,7 @@ describe('createFinanceCommandService', () => {
amountMinor: 12000n, amountMinor: 12000n,
currency: 'USD', currency: 'USD',
createdByMemberId: 'alice', createdByMemberId: 'alice',
createdAt: new Date('2026-03-12T12:00:00.000Z') createdAt: instantFromIso('2026-03-12T12:00:00.000Z')
} }
] ]
repository.purchases = [ repository.purchases = [
@@ -189,7 +190,7 @@ describe('createFinanceCommandService', () => {
payerMemberId: 'alice', payerMemberId: 'alice',
amountMinor: 3000n, amountMinor: 3000n,
description: 'Soap', description: 'Soap',
occurredAt: new Date('2026-03-12T11:00:00.000Z') occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
} }
] ]

View File

@@ -7,6 +7,8 @@ import {
MemberId, MemberId,
Money, Money,
PurchaseEntryId, PurchaseEntryId,
Temporal,
nowInstant,
type CurrencyCode type CurrencyCode
} from '@household/domain' } from '@household/domain'
@@ -25,10 +27,13 @@ function parseCurrency(raw: string | undefined, fallback: CurrencyCode): Currenc
return normalized return normalized
} }
function monthRange(period: BillingPeriod): { start: Date; end: Date } { function monthRange(period: BillingPeriod): {
start: Temporal.Instant
end: Temporal.Instant
} {
return { return {
start: new Date(Date.UTC(period.year, period.month - 1, 1, 0, 0, 0)), start: Temporal.Instant.from(`${period.toString()}-01T00:00:00Z`),
end: new Date(Date.UTC(period.year, period.month, 0, 23, 59, 59)) end: Temporal.Instant.from(`${period.next().toString()}-01T00:00:00Z`)
} }
} }
@@ -161,7 +166,7 @@ async function buildFinanceDashboard(
actorDisplayName: bill.createdByMemberId actorDisplayName: bill.createdByMemberId
? (memberNameById.get(bill.createdByMemberId) ?? null) ? (memberNameById.get(bill.createdByMemberId) ?? null)
: null, : null,
occurredAt: bill.createdAt.toISOString() occurredAt: bill.createdAt.toString()
})), })),
...purchases.map((purchase) => ({ ...purchases.map((purchase) => ({
id: purchase.id, id: purchase.id,
@@ -169,7 +174,7 @@ async function buildFinanceDashboard(
title: purchase.description ?? 'Shared purchase', title: purchase.description ?? 'Shared purchase',
amount: Money.fromMinor(purchase.amountMinor, rentRule.currency), amount: Money.fromMinor(purchase.amountMinor, rentRule.currency),
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null, actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
occurredAt: purchase.occurredAt?.toISOString() ?? null occurredAt: purchase.occurredAt?.toString() ?? null
})) }))
].sort((left, right) => { ].sort((left, right) => {
if (left.occurredAt === right.occurredAt) { if (left.occurredAt === right.occurredAt) {
@@ -246,7 +251,7 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
return null return null
} }
await repository.closeCycle(cycle.id, new Date()) await repository.closeCycle(cycle.id, nowInstant())
return cycle return cycle
}, },

View File

@@ -10,5 +10,8 @@
"typecheck": "tsgo --project tsconfig.json --noEmit", "typecheck": "tsgo --project tsconfig.json --noEmit",
"test": "bun test --pass-with-no-tests", "test": "bun test --pass-with-no-tests",
"lint": "oxlint \"src\"" "lint": "oxlint \"src\""
},
"dependencies": {
"@js-temporal/polyfill": "^0.5.1"
} }
} }

View File

@@ -1,3 +1,5 @@
import type { Instant } from './time'
import { DOMAIN_ERROR_CODE, DomainError } from './errors' import { DOMAIN_ERROR_CODE, DomainError } from './errors'
const BILLING_PERIOD_PATTERN = /^(\d{4})-(\d{2})$/ const BILLING_PERIOD_PATTERN = /^(\d{4})-(\d{2})$/
@@ -48,8 +50,10 @@ export class BillingPeriod {
return BillingPeriod.from(Number(yearString), Number(monthString)) return BillingPeriod.from(Number(yearString), Number(monthString))
} }
static fromDate(date: Date): BillingPeriod { static fromInstant(instant: Instant): BillingPeriod {
return BillingPeriod.from(date.getUTCFullYear(), date.getUTCMonth() + 1) const zoned = instant.toZonedDateTimeISO('UTC')
return BillingPeriod.from(zoned.year, zoned.month)
} }
next(): BillingPeriod { next(): BillingPeriod {

View File

@@ -2,7 +2,18 @@ export { BillingPeriod } from './billing-period'
export { DOMAIN_ERROR_CODE, DomainError } from './errors' export { DOMAIN_ERROR_CODE, DomainError } from './errors'
export { BillingCycleId, HouseholdId, MemberId, PurchaseEntryId } from './ids' export { BillingCycleId, HouseholdId, MemberId, PurchaseEntryId } from './ids'
export { CURRENCIES, Money } from './money' export { CURRENCIES, Money } from './money'
export {
Temporal,
instantFromDatabaseValue,
instantFromDate,
instantFromEpochSeconds,
instantFromIso,
instantToDate,
instantToEpochSeconds,
nowInstant
} from './time'
export type { CurrencyCode } from './money' export type { CurrencyCode } from './money'
export type { Instant } from './time'
export type { export type {
SettlementInput, SettlementInput,
SettlementMemberInput, SettlementMemberInput,

View File

@@ -0,0 +1,41 @@
import { Temporal } from '@js-temporal/polyfill'
export { Temporal }
export type Instant = Temporal.Instant
export function nowInstant(): Instant {
return Temporal.Now.instant()
}
export function instantFromEpochSeconds(epochSeconds: number): Instant {
return Temporal.Instant.fromEpochMilliseconds(epochSeconds * 1000)
}
export function instantToEpochSeconds(instant: Instant): number {
return Math.floor(instant.epochMilliseconds / 1000)
}
export function instantFromDate(date: Date): Instant {
return Temporal.Instant.fromEpochMilliseconds(date.getTime())
}
export function instantToDate(instant: Instant): Date {
return new Date(instant.epochMilliseconds)
}
export function instantFromIso(value: string): Instant {
return Temporal.Instant.from(value)
}
export function instantFromDatabaseValue(value: Date | string | null): Instant | null {
if (value instanceof Date) {
return instantFromDate(value)
}
if (typeof value === 'string') {
return instantFromIso(value)
}
return null
}

View File

@@ -1,3 +1,5 @@
import type { Instant } from '@household/domain'
export type AnonymousFeedbackModerationStatus = 'accepted' | 'posted' | 'rejected' | 'failed' export type AnonymousFeedbackModerationStatus = 'accepted' | 'posted' | 'rejected' | 'failed'
export type AnonymousFeedbackRejectionReason = export type AnonymousFeedbackRejectionReason =
@@ -16,7 +18,8 @@ export interface AnonymousFeedbackMemberRecord {
export interface AnonymousFeedbackRateLimitSnapshot { export interface AnonymousFeedbackRateLimitSnapshot {
acceptedCountSince: number acceptedCountSince: number
lastAcceptedAt: Date | null earliestAcceptedAtSince: Instant | null
lastAcceptedAt: Instant | null
} }
export interface AnonymousFeedbackSubmissionRecord { export interface AnonymousFeedbackSubmissionRecord {
@@ -28,7 +31,7 @@ export interface AnonymousFeedbackRepository {
getMemberByTelegramUserId(telegramUserId: string): Promise<AnonymousFeedbackMemberRecord | null> getMemberByTelegramUserId(telegramUserId: string): Promise<AnonymousFeedbackMemberRecord | null>
getRateLimitSnapshot( getRateLimitSnapshot(
memberId: string, memberId: string,
acceptedSince: Date acceptedSince: Instant
): Promise<AnonymousFeedbackRateLimitSnapshot> ): Promise<AnonymousFeedbackRateLimitSnapshot>
createSubmission(input: { createSubmission(input: {
submittedByMemberId: string submittedByMemberId: string
@@ -45,7 +48,7 @@ export interface AnonymousFeedbackRepository {
postedChatId: string postedChatId: string
postedThreadId: string postedThreadId: string
postedMessageId: string postedMessageId: string
postedAt: Date postedAt: Instant
}): Promise<void> }): Promise<void>
markFailed(submissionId: string, failureReason: string): Promise<void> markFailed(submissionId: string, failureReason: string): Promise<void>
} }

View File

@@ -1,4 +1,4 @@
import type { CurrencyCode } from '@household/domain' import type { CurrencyCode, Instant } from '@household/domain'
export interface FinanceMemberRecord { export interface FinanceMemberRecord {
id: string id: string
@@ -23,7 +23,7 @@ export interface FinanceParsedPurchaseRecord {
payerMemberId: string payerMemberId: string
amountMinor: bigint amountMinor: bigint
description: string | null description: string | null
occurredAt: Date | null occurredAt: Instant | null
} }
export interface FinanceUtilityBillRecord { export interface FinanceUtilityBillRecord {
@@ -32,7 +32,7 @@ export interface FinanceUtilityBillRecord {
amountMinor: bigint amountMinor: bigint
currency: CurrencyCode currency: CurrencyCode
createdByMemberId: string | null createdByMemberId: string | null
createdAt: Date createdAt: Instant
} }
export interface SettlementSnapshotLineRecord { export interface SettlementSnapshotLineRecord {
@@ -60,7 +60,7 @@ export interface FinanceRepository {
getCycleByPeriod(period: string): Promise<FinanceCycleRecord | null> getCycleByPeriod(period: string): Promise<FinanceCycleRecord | null>
getLatestCycle(): Promise<FinanceCycleRecord | null> getLatestCycle(): Promise<FinanceCycleRecord | null>
openCycle(period: string, currency: CurrencyCode): Promise<void> openCycle(period: string, currency: CurrencyCode): Promise<void>
closeCycle(cycleId: string, closedAt: Date): Promise<void> closeCycle(cycleId: string, closedAt: Instant): Promise<void>
saveRentRule(period: string, amountMinor: bigint, currency: CurrencyCode): Promise<void> saveRentRule(period: string, amountMinor: bigint, currency: CurrencyCode): Promise<void>
addUtilityBill(input: { addUtilityBill(input: {
cycleId: string cycleId: string
@@ -73,8 +73,8 @@ export interface FinanceRepository {
getUtilityTotalForCycle(cycleId: string): Promise<bigint> getUtilityTotalForCycle(cycleId: string): Promise<bigint>
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]> listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
listParsedPurchasesForRange( listParsedPurchasesForRange(
start: Date, start: Instant,
end: Date end: Instant
): Promise<readonly FinanceParsedPurchaseRecord[]> ): Promise<readonly FinanceParsedPurchaseRecord[]>
replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void> replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void>
} }

View File

@@ -1,3 +1,5 @@
import type { Instant } from '@household/domain'
export const TELEGRAM_PENDING_ACTION_TYPES = ['anonymous_feedback'] as const export const TELEGRAM_PENDING_ACTION_TYPES = ['anonymous_feedback'] as const
export type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number] export type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number]
@@ -7,7 +9,7 @@ export interface TelegramPendingActionRecord {
telegramChatId: string telegramChatId: string
action: TelegramPendingActionType action: TelegramPendingActionType
payload: Record<string, unknown> payload: Record<string, unknown>
expiresAt: Date | null expiresAt: Instant | null
} }
export interface TelegramPendingActionRepository { export interface TelegramPendingActionRepository {