feat(miniapp): carry overdue billing and admin role flows

This commit is contained in:
2026-03-23 15:44:55 +04:00
parent ee8c53d89b
commit 5af14e101e
44 changed files with 2965 additions and 329 deletions

View File

@@ -256,6 +256,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
isActive: input.isActive isActive: input.isActive
}), }),
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null, updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [], listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -40,6 +40,7 @@ import {
createMiniAppApproveMemberHandler, createMiniAppApproveMemberHandler,
createMiniAppPendingMembersHandler, createMiniAppPendingMembersHandler,
createMiniAppPromoteMemberHandler, createMiniAppPromoteMemberHandler,
createMiniAppDemoteMemberHandler,
createMiniAppRejectMemberHandler, createMiniAppRejectMemberHandler,
createMiniAppSettingsHandler, createMiniAppSettingsHandler,
createMiniAppUpdateMemberAbsencePolicyHandler, createMiniAppUpdateMemberAbsencePolicyHandler,
@@ -634,6 +635,15 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
logger: getLogger('miniapp-admin') logger: getLogger('miniapp-admin')
}) })
: undefined, : undefined,
miniAppDemoteMember: householdOnboardingService
? createMiniAppDemoteMemberHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateOwnDisplayName: householdOnboardingService miniAppUpdateOwnDisplayName: householdOnboardingService
? createMiniAppUpdateOwnDisplayNameHandler({ ? createMiniAppUpdateOwnDisplayNameHandler({
allowedOrigins: runtime.miniAppAllowedOrigins, allowedOrigins: runtime.miniAppAllowedOrigins,

View File

@@ -135,6 +135,7 @@ function createRepository(isAdmin = false): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null, updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null, updateHouseholdMemberDisplayName: async () => null,
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null, updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [], listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -267,6 +267,7 @@ function createHouseholdRepository(): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null, updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null, updateHouseholdMemberDisplayName: async () => null,
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null, updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [], listHouseholdMemberAbsencePolicies: async () => [],
@@ -366,6 +367,7 @@ function createFinanceService(): FinanceCommandService {
netDue: Money.fromMajor('850.00', 'GEL'), netDue: Money.fromMajor('850.00', 'GEL'),
paid: Money.fromMajor('500.00', 'GEL'), paid: Money.fromMajor('500.00', 'GEL'),
remaining: Money.fromMajor('350.00', 'GEL'), remaining: Money.fromMajor('350.00', 'GEL'),
overduePayments: [],
explanations: [] explanations: []
}, },
{ {
@@ -377,6 +379,7 @@ function createFinanceService(): FinanceCommandService {
netDue: Money.fromMajor('815.00', 'GEL'), netDue: Money.fromMajor('815.00', 'GEL'),
paid: Money.fromMajor('200.00', 'GEL'), paid: Money.fromMajor('200.00', 'GEL'),
remaining: Money.fromMajor('615.00', 'GEL'), remaining: Money.fromMajor('615.00', 'GEL'),
overduePayments: [],
explanations: [] explanations: []
}, },
{ {
@@ -388,6 +391,7 @@ function createFinanceService(): FinanceCommandService {
netDue: Money.fromMajor('680.00', 'GEL'), netDue: Money.fromMajor('680.00', 'GEL'),
paid: Money.fromMajor('100.00', 'GEL'), paid: Money.fromMajor('100.00', 'GEL'),
remaining: Money.fromMajor('580.00', 'GEL'), remaining: Money.fromMajor('580.00', 'GEL'),
overduePayments: [],
explanations: [] explanations: []
} }
], ],

View File

@@ -113,6 +113,7 @@ function createRepository(): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null, updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null, updateHouseholdMemberDisplayName: async () => null,
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null, updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [], listHouseholdMemberAbsencePolicies: async () => [],
@@ -150,6 +151,7 @@ function createDashboard(): NonNullable<
netDue: Money.fromMajor('210', 'GEL'), netDue: Money.fromMajor('210', 'GEL'),
paid: Money.fromMajor('100', 'GEL'), paid: Money.fromMajor('100', 'GEL'),
remaining: Money.fromMajor('110', 'GEL'), remaining: Money.fromMajor('110', 'GEL'),
overduePayments: [],
explanations: [] explanations: []
}, },
{ {
@@ -161,6 +163,7 @@ function createDashboard(): NonNullable<
netDue: Money.fromMajor('190', 'GEL'), netDue: Money.fromMajor('190', 'GEL'),
paid: Money.zero('GEL'), paid: Money.zero('GEL'),
remaining: Money.fromMajor('190', 'GEL'), remaining: Money.fromMajor('190', 'GEL'),
overduePayments: [],
explanations: [] explanations: []
} }
], ],

View File

@@ -484,6 +484,9 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
async promoteHouseholdAdmin() { async promoteHouseholdAdmin() {
return null return null
}, },
async demoteHouseholdAdmin() {
return null
},
async updateHouseholdMemberRentShareWeight() { async updateHouseholdMemberRentShareWeight() {
return null return null
}, },

View File

@@ -8,6 +8,7 @@ import type {
import { import {
createMiniAppApproveMemberHandler, createMiniAppApproveMemberHandler,
createMiniAppDemoteMemberHandler,
createMiniAppRejectMemberHandler, createMiniAppRejectMemberHandler,
createMiniAppPendingMembersHandler, createMiniAppPendingMembersHandler,
createMiniAppPromoteMemberHandler, createMiniAppPromoteMemberHandler,
@@ -230,6 +231,28 @@ function onboardingRepository(): HouseholdConfigurationRepository {
} }
: null : null
}, },
demoteHouseholdAdmin: async (householdId, memberId) => {
const member = [
{
id: 'member-123456',
householdId,
telegramUserId: '123456',
displayName: 'Stan',
status: 'active' as const,
preferredLocale: null,
householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: true
}
].find((entry) => entry.id === memberId)
return member
? {
...member,
isAdmin: false
}
: null
},
updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) => updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) =>
memberId === 'member-123456' memberId === 'member-123456'
? { ? {
@@ -776,6 +799,95 @@ describe('createMiniAppPromoteMemberHandler', () => {
}) })
}) })
describe('createMiniAppDemoteMemberHandler', () => {
test('removes admin access from a household member for an authenticated admin', async () => {
const authDate = Math.floor(Date.now() / 1000)
const repository = onboardingRepository()
repository.listHouseholdMembersByTelegramUserId = async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
}
]
repository.listHouseholdMembers = async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
},
{
id: 'member-555777',
householdId: 'household-1',
telegramUserId: '555777',
displayName: 'Mia',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
}
]
const handler = createMiniAppDemoteMemberHandler({
allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token',
onboardingService: createHouseholdOnboardingService({
repository
}),
miniAppAdminService: createMiniAppAdminService(repository)
})
const response = await handler.handler(
new Request('http://localhost/api/miniapp/admin/members/demote', {
method: 'POST',
headers: {
origin: 'http://localhost:5173',
'content-type': 'application/json'
},
body: JSON.stringify({
initData: buildMiniAppInitData('test-bot-token', authDate, {
id: 123456,
first_name: 'Stan',
username: 'stanislav',
language_code: 'ru'
}),
memberId: 'member-123456'
})
})
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
ok: true,
authorized: true,
member: {
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
})
})
})
describe('createMiniAppUpdateOwnDisplayNameHandler', () => { describe('createMiniAppUpdateOwnDisplayNameHandler', () => {
test('updates the acting member display name for an authenticated member', async () => { test('updates the acting member display name for an authenticated member', async () => {
const authDate = Math.floor(Date.now() / 1000) const authDate = Math.floor(Date.now() / 1000)

View File

@@ -898,6 +898,94 @@ export function createMiniAppPromoteMemberHandler(options: {
} }
} }
export function createMiniAppDemoteMemberHandler(options: {
allowedOrigins: readonly string[]
botToken: string
onboardingService: HouseholdOnboardingService
miniAppAdminService: MiniAppAdminService
logger?: Logger
}): {
handler: (request: Request) => Promise<Response>
} {
const sessionService = createMiniAppSessionService({
botToken: options.botToken,
onboardingService: options.onboardingService
})
return {
handler: async (request) => {
const origin = allowedMiniAppOrigin(request, options.allowedOrigins)
if (request.method === 'OPTIONS') {
return miniAppJsonResponse({ ok: true }, 204, origin)
}
if (request.method !== 'POST') {
return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin)
}
try {
const payload = await readPromoteMemberPayload(request)
const session = await sessionService.authenticate({
initData: payload.initData
})
if (!session) {
return miniAppJsonResponse(
{ ok: false, error: 'Invalid Telegram init data' },
401,
origin
)
}
if (
!session.authorized ||
!session.member ||
session.member.status !== 'active' ||
!session.member.isAdmin
) {
return miniAppJsonResponse(
{ ok: false, error: 'Admin access required for active household members' },
403,
origin
)
}
const result = await options.miniAppAdminService.demoteMemberFromAdmin({
householdId: session.member.householdId,
actorIsAdmin: session.member.isAdmin,
memberId: payload.memberId
})
if (result.status === 'rejected') {
const status =
result.reason === 'member_not_found' ? 404 : result.reason === 'last_admin' ? 409 : 403
const error =
result.reason === 'member_not_found'
? 'Member not found'
: result.reason === 'last_admin'
? 'Cannot remove the last household admin'
: 'Admin access required'
return miniAppJsonResponse({ ok: false, error }, status, origin)
}
return miniAppJsonResponse(
{
ok: true,
authorized: true,
member: result.member
},
200,
origin
)
} catch (error) {
return miniAppErrorResponse(error, origin, options.logger)
}
}
}
}
export function createMiniAppUpdateOwnDisplayNameHandler(options: { export function createMiniAppUpdateOwnDisplayNameHandler(options: {
allowedOrigins: readonly string[] allowedOrigins: readonly string[]
botToken: string botToken: string

View File

@@ -191,7 +191,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
sortOrder: input.sortOrder, sortOrder: input.sortOrder,
isActive: input.isActive isActive: input.isActive
}), }),
promoteHouseholdAdmin: async () => null promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null
} }
} }

View File

@@ -137,6 +137,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null, updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null, updateHouseholdMemberDisplayName: async () => null,
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null, updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [], listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -486,6 +486,7 @@ async function readPaymentMutationPayload(request: Request): Promise<{
kind?: 'rent' | 'utilities' kind?: 'rent' | 'utilities'
amountMajor?: string amountMajor?: string
currency?: string currency?: string
period?: string
}> { }> {
const parsed = await parseJsonBody<{ const parsed = await parseJsonBody<{
initData?: string initData?: string
@@ -494,6 +495,7 @@ async function readPaymentMutationPayload(request: Request): Promise<{
kind?: 'rent' | 'utilities' kind?: 'rent' | 'utilities'
amountMajor?: string amountMajor?: string
currency?: string currency?: string
period?: string
}>(request) }>(request)
const initData = parsed.initData?.trim() const initData = parsed.initData?.trim()
if (!initData) { if (!initData) {
@@ -526,6 +528,11 @@ async function readPaymentMutationPayload(request: Request): Promise<{
? { ? {
currency: parsed.currency.trim() currency: parsed.currency.trim()
} }
: {}),
...(parsed.period?.trim()
? {
period: BillingPeriod.fromString(parsed.period.trim()).toString()
}
: {}) : {})
} }
} }
@@ -1001,7 +1008,8 @@ export function createMiniAppSubmitPaymentHandler(options: {
auth.member.id, auth.member.id,
payload.kind, payload.kind,
payload.amountMajor, payload.amountMajor,
payload.currency payload.currency,
payload.period
) )
if (!payment) { if (!payment) {
@@ -1374,7 +1382,8 @@ export function createMiniAppAddPaymentHandler(options: {
payload.memberId, payload.memberId,
payload.kind, payload.kind,
payload.amountMajor, payload.amountMajor,
payload.currency payload.currency,
payload.period
) )
if (!payment) { if (!payment) {

View File

@@ -54,6 +54,7 @@ function repository(
isAdmin: true isAdmin: true
} }
], ],
listCycles: async () => [cycle],
getOpenCycle: async () => cycle, getOpenCycle: async () => cycle,
getCycleByPeriod: async (period) => (period === cycle.period ? cycle : null), getCycleByPeriod: async (period) => (period === cycle.period ? cycle : null),
getLatestCycle: async () => cycle, getLatestCycle: async () => cycle,
@@ -72,6 +73,8 @@ function repository(
updateParsedPurchase: async () => null, updateParsedPurchase: async () => null,
addParsedPurchase: async (input) => ({ addParsedPurchase: async (input) => ({
id: 'purchase-new', id: 'purchase-new',
cycleId: input.cycleId,
cyclePeriod: null,
payerMemberId: input.payerMemberId, payerMemberId: input.payerMemberId,
amountMinor: input.amountMinor, amountMinor: input.amountMinor,
currency: input.currency, currency: input.currency,
@@ -90,6 +93,8 @@ function repository(
deleteUtilityBill: async () => false, deleteUtilityBill: async () => false,
addPaymentRecord: async (input) => ({ addPaymentRecord: async (input) => ({
id: 'payment-new', id: 'payment-new',
cycleId: input.cycleId,
cyclePeriod: null,
memberId: input.memberId, memberId: input.memberId,
kind: input.kind, kind: input.kind,
amountMinor: input.amountMinor, amountMinor: input.amountMinor,
@@ -97,6 +102,8 @@ function repository(
recordedAt: input.recordedAt recordedAt: input.recordedAt
}), }),
updatePaymentRecord: async () => null, updatePaymentRecord: async () => null,
getPaymentRecord: async () => null,
replacePaymentPurchaseAllocations: async () => {},
deletePaymentRecord: async () => false, deletePaymentRecord: async () => false,
getRentRuleForPeriod: async () => ({ getRentRuleForPeriod: async () => ({
amountMinor: 70000n, amountMinor: 70000n,
@@ -116,6 +123,8 @@ function repository(
listPaymentRecordsForCycle: async () => [ listPaymentRecordsForCycle: async () => [
{ {
id: 'payment-1', id: 'payment-1',
cycleId: cycle.id,
cyclePeriod: cycle.period,
memberId: member?.id ?? 'member-1', memberId: member?.id ?? 'member-1',
kind: 'rent', kind: 'rent',
amountMinor: 50000n, amountMinor: 50000n,
@@ -126,6 +135,8 @@ function repository(
listParsedPurchasesForRange: async () => [ listParsedPurchasesForRange: async () => [
{ {
id: 'purchase-1', id: 'purchase-1',
cycleId: cycle.id,
cyclePeriod: cycle.period,
payerMemberId: member?.id ?? 'member-1', payerMemberId: member?.id ?? 'member-1',
amountMinor: 3000n, amountMinor: 3000n,
currency: 'GEL', currency: 'GEL',
@@ -133,6 +144,19 @@ function repository(
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z') occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
} }
], ],
listParsedPurchases: async () => [
{
id: 'purchase-1',
cycleId: cycle.id,
cyclePeriod: cycle.period,
payerMemberId: member?.id ?? 'member-1',
amountMinor: 3000n,
currency: 'GEL',
description: 'Soap',
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
}
],
listPaymentPurchaseAllocations: async () => [],
getSettlementSnapshotLines: async () => [], getSettlementSnapshotLines: async () => [],
savePaymentConfirmation: async () => savePaymentConfirmation: async () =>
({ ({
@@ -282,6 +306,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
isActive: input.isActive isActive: input.isActive
}), }),
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null, updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [], listHouseholdMemberAbsencePolicies: async () => [],
@@ -364,6 +389,7 @@ describe('createMiniAppDashboardHandler', () => {
{ {
displayName: 'Stan', displayName: 'Stan',
netDueMajor: '2010.00', netDueMajor: '2010.00',
overduePayments: [],
paidMajor: '500.00', paidMajor: '500.00',
remainingMajor: '1510.00', remainingMajor: '1510.00',
rentShareMajor: '1890.00', rentShareMajor: '1890.00',
@@ -408,6 +434,8 @@ describe('createMiniAppDashboardHandler', () => {
financeRepository.listParsedPurchasesForRange = async () => [ financeRepository.listParsedPurchasesForRange = async () => [
{ {
id: 'purchase-1', id: 'purchase-1',
cycleId: 'cycle-1',
cyclePeriod: '2026-03',
payerMemberId: 'member-1', payerMemberId: 'member-1',
amountMinor: 3000n, amountMinor: 3000n,
currency: 'GEL', currency: 'GEL',
@@ -433,6 +461,11 @@ describe('createMiniAppDashboardHandler', () => {
] ]
} }
] ]
financeRepository.listParsedPurchases = async () =>
financeRepository.listParsedPurchasesForRange(
instantFromIso('2026-03-01T00:00:00.000Z'),
instantFromIso('2026-04-01T00:00:00.000Z')
)
financeRepository.listMembers = async () => [ financeRepository.listMembers = async () => [
{ {
id: 'member-1', id: 'member-1',

View File

@@ -1,4 +1,5 @@
import type { FinanceCommandService, HouseholdOnboardingService } from '@household/application' import type { FinanceCommandService, HouseholdOnboardingService } from '@household/application'
import { Money } from '@household/domain'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
import { import {
@@ -113,6 +114,14 @@ export function createMiniAppDashboardHandler(options: {
netDueMajor: line.netDue.toMajorString(), netDueMajor: line.netDue.toMajorString(),
paidMajor: line.paid.toMajorString(), paidMajor: line.paid.toMajorString(),
remainingMajor: line.remaining.toMajorString(), remainingMajor: line.remaining.toMajorString(),
overduePayments: line.overduePayments.map((overdue) => ({
kind: overdue.kind,
amountMajor: Money.fromMinor(
overdue.amountMinor,
dashboard.currency
).toMajorString(),
periods: overdue.periods
})),
explanations: line.explanations explanations: line.explanations
})), })),
ledger: dashboard.ledger.map((entry) => ({ ledger: dashboard.ledger.map((entry) => ({
@@ -132,6 +141,14 @@ export function createMiniAppDashboardHandler(options: {
...(entry.kind === 'purchase' ...(entry.kind === 'purchase'
? { ? {
purchaseSplitMode: entry.purchaseSplitMode ?? 'equal', purchaseSplitMode: entry.purchaseSplitMode ?? 'equal',
originPeriod: entry.originPeriod ?? null,
resolutionStatus: entry.resolutionStatus ?? 'unresolved',
resolvedAt: entry.resolvedAt ?? null,
outstandingByMember:
entry.outstandingByMember?.map((outstanding) => ({
memberId: outstanding.memberId,
amountMajor: outstanding.amount.toMajorString()
})) ?? [],
purchaseParticipants: purchaseParticipants:
entry.purchaseParticipants?.map((participant) => ({ entry.purchaseParticipants?.map((participant) => ({
memberId: participant.memberId, memberId: participant.memberId,

View File

@@ -168,6 +168,7 @@ function repository(): HouseholdConfigurationRepository {
isActive: input.isActive isActive: input.isActive
}), }),
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null, updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [], listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -198,6 +198,7 @@ function createFinanceService(): FinanceCommandService {
netDue: Money.fromMajor('500.50', 'GEL'), netDue: Money.fromMajor('500.50', 'GEL'),
paid: Money.zero('GEL'), paid: Money.zero('GEL'),
remaining: Money.fromMajor('500.50', 'GEL'), remaining: Money.fromMajor('500.50', 'GEL'),
overduePayments: [],
explanations: [] explanations: []
} }
], ],

View File

@@ -62,6 +62,12 @@ export interface BotWebhookServerOptions {
handler: (request: Request) => Promise<Response> handler: (request: Request) => Promise<Response>
} }
| undefined | undefined
miniAppDemoteMember?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppUpdateOwnDisplayName?: miniAppUpdateOwnDisplayName?:
| { | {
path?: string path?: string
@@ -234,6 +240,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
options.miniAppUpsertUtilityCategory?.path ?? '/api/miniapp/admin/utility-categories/upsert' options.miniAppUpsertUtilityCategory?.path ?? '/api/miniapp/admin/utility-categories/upsert'
const miniAppPromoteMemberPath = const miniAppPromoteMemberPath =
options.miniAppPromoteMember?.path ?? '/api/miniapp/admin/members/promote' options.miniAppPromoteMember?.path ?? '/api/miniapp/admin/members/promote'
const miniAppDemoteMemberPath =
options.miniAppDemoteMember?.path ?? '/api/miniapp/admin/members/demote'
const miniAppUpdateOwnDisplayNamePath = const miniAppUpdateOwnDisplayNamePath =
options.miniAppUpdateOwnDisplayName?.path ?? '/api/miniapp/member/display-name' options.miniAppUpdateOwnDisplayName?.path ?? '/api/miniapp/member/display-name'
const miniAppUpdateMemberDisplayNamePath = const miniAppUpdateMemberDisplayNamePath =
@@ -328,6 +336,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
return await options.miniAppPromoteMember.handler(request) return await options.miniAppPromoteMember.handler(request)
} }
if (options.miniAppDemoteMember && url.pathname === miniAppDemoteMemberPath) {
return await options.miniAppDemoteMember.handler(request)
}
if (options.miniAppUpdateOwnDisplayName && url.pathname === miniAppUpdateOwnDisplayNamePath) { if (options.miniAppUpdateOwnDisplayName && url.pathname === miniAppUpdateOwnDisplayNamePath) {
return await options.miniAppUpdateOwnDisplayName.handler(request) return await options.miniAppUpdateOwnDisplayName.handler(request)
} }

View File

@@ -20,10 +20,13 @@ export function AppShell(props: ParentProps) {
effectiveIsAdmin, effectiveIsAdmin,
testingRolePreview, testingRolePreview,
setTestingRolePreview, setTestingRolePreview,
demoScenario,
setDemoScenario,
testingPeriodOverride, testingPeriodOverride,
setTestingPeriodOverride, setTestingPeriodOverride,
testingTodayOverride, testingTodayOverride,
setTestingTodayOverride setTestingTodayOverride,
applyDemoState
} = useDashboard() } = useDashboard()
const navigate = useNavigate() const navigate = useNavigate()
@@ -38,6 +41,28 @@ export function AppShell(props: ParentProps) {
return labels[status] return labels[status]
} }
function demoScenarioLabel(
id: 'current-cycle' | 'overdue-utilities' | 'overdue-rent-and-utilities'
) {
const labels = {
'current-cycle': copy().testingScenarioCurrentCycle ?? '',
'overdue-utilities': copy().testingScenarioOverdueUtilities ?? '',
'overdue-rent-and-utilities': copy().testingScenarioOverdueBoth ?? ''
}
return labels[id]
}
function demoScenarioDescription(
id: 'current-cycle' | 'overdue-utilities' | 'overdue-rent-and-utilities'
) {
const descriptions = {
'current-cycle': copy().testingScenarioCurrentCycleBody ?? '',
'overdue-utilities': copy().testingScenarioOverdueUtilitiesBody ?? '',
'overdue-rent-and-utilities': copy().testingScenarioOverdueBothBody ?? ''
}
return descriptions[id]
}
let tapCount = 0 let tapCount = 0
let tapTimer: ReturnType<typeof setTimeout> | undefined let tapTimer: ReturnType<typeof setTimeout> | undefined
function handleRoleChipTap() { function handleRoleChipTap() {
@@ -92,6 +117,9 @@ export function AppShell(props: ParentProps) {
<Badge variant={readySession()?.mode === 'demo' ? 'accent' : 'default'}> <Badge variant={readySession()?.mode === 'demo' ? 'accent' : 'default'}>
{readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge} {readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge}
</Badge> </Badge>
<Show when={readySession()?.mode === 'demo'}>
<Badge variant="muted">{demoScenarioLabel(demoScenario())}</Badge>
</Show>
<Show <Show
when={readySession()?.member.isAdmin} when={readySession()?.member.isAdmin}
fallback={ fallback={
@@ -168,11 +196,47 @@ export function AppShell(props: ParentProps) {
{copy().testingPreviewResidentAction ?? ''} {copy().testingPreviewResidentAction ?? ''}
</Button> </Button>
</div> </div>
<Show when={readySession()?.mode === 'demo'}>
<article class="testing-card__section testing-card__section--stack">
<span>{copy().testingScenarioLabel ?? ''}</span>
<div class="testing-card__section-content">
<strong>{demoScenarioLabel(demoScenario())}</strong>
<p class="testing-card__section-description">
{demoScenarioDescription(demoScenario())}
</p>
</div>
</article>
<div class="testing-card__actions testing-card__actions--wrap">
<Button
variant={demoScenario() === 'current-cycle' ? 'primary' : 'secondary'}
onClick={() => setDemoScenario('current-cycle')}
>
{copy().testingScenarioCurrentCycle ?? ''}
</Button>
<Button
variant={demoScenario() === 'overdue-utilities' ? 'primary' : 'secondary'}
onClick={() => setDemoScenario('overdue-utilities')}
>
{copy().testingScenarioOverdueUtilities ?? ''}
</Button>
<Button
variant={demoScenario() === 'overdue-rent-and-utilities' ? 'primary' : 'secondary'}
onClick={() => setDemoScenario('overdue-rent-and-utilities')}
>
{copy().testingScenarioOverdueBoth ?? ''}
</Button>
</div>
<div class="modal-action-row">
<Button variant="ghost" onClick={() => applyDemoState()}>
{copy().testingResetDemoStateAction ?? ''}
</Button>
</div>
</Show>
<article class="testing-card__section"> <article class="testing-card__section">
<span>{copy().testingPeriodCurrentLabel ?? ''}</span> <span>{copy().testingPeriodCurrentLabel ?? ''}</span>
<strong>{dashboard()?.period ?? '—'}</strong> <strong>{dashboard()?.period ?? '—'}</strong>
</article> </article>
<div class="testing-card__actions" style={{ 'flex-direction': 'column', gap: '12px' }}> <div class="testing-card__actions testing-card__actions--stack">
<Field label={copy().testingPeriodOverrideLabel ?? ''} wide> <Field label={copy().testingPeriodOverrideLabel ?? ''} wide>
<Input <Input
placeholder={copy().testingPeriodOverridePlaceholder ?? ''} placeholder={copy().testingPeriodOverridePlaceholder ?? ''}

View File

@@ -21,12 +21,7 @@ import type {
MiniAppDashboard, MiniAppDashboard,
MiniAppPendingMember MiniAppPendingMember
} from '../miniapp-api' } from '../miniapp-api'
import { import { getDemoScenarioState, type DemoScenarioId } from '../demo/miniapp-demo'
demoAdminSettings,
demoCycleState,
demoDashboard,
demoPendingMembers
} from '../demo/miniapp-demo'
import { useSession } from './session-context' import { useSession } from './session-context'
import { useI18n } from './i18n-context' import { useI18n } from './i18n-context'
@@ -106,6 +101,8 @@ type DashboardContextValue = {
memberUtilityBalanceVisuals: () => MemberBalanceItem[] memberUtilityBalanceVisuals: () => MemberBalanceItem[]
testingRolePreview: () => TestingRolePreview | null testingRolePreview: () => TestingRolePreview | null
setTestingRolePreview: (value: TestingRolePreview | null) => void setTestingRolePreview: (value: TestingRolePreview | null) => void
demoScenario: () => DemoScenarioId
setDemoScenario: (value: DemoScenarioId) => void
testingPeriodOverride: () => string | null testingPeriodOverride: () => string | null
setTestingPeriodOverride: (value: string | null) => void setTestingPeriodOverride: (value: string | null) => void
testingTodayOverride: () => string | null testingTodayOverride: () => string | null
@@ -297,6 +294,7 @@ export function DashboardProvider(props: ParentProps) {
const [cycleState, setCycleState] = createSignal<MiniAppAdminCycleState | null>(null) const [cycleState, setCycleState] = createSignal<MiniAppAdminCycleState | null>(null)
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([]) const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
const [testingRolePreview, setTestingRolePreview] = createSignal<TestingRolePreview | null>(null) const [testingRolePreview, setTestingRolePreview] = createSignal<TestingRolePreview | null>(null)
const [demoScenario, setDemoScenarioSignal] = createSignal<DemoScenarioId>('current-cycle')
const [testingPeriodOverride, setTestingPeriodOverride] = createSignal<string | null>(null) const [testingPeriodOverride, setTestingPeriodOverride] = createSignal<string | null>(null)
const [testingTodayOverride, setTestingTodayOverride] = createSignal<string | null>(null) const [testingTodayOverride, setTestingTodayOverride] = createSignal<string | null>(null)
@@ -393,10 +391,22 @@ export function DashboardProvider(props: ParentProps) {
} }
function applyDemoState() { function applyDemoState() {
setDashboard(demoDashboard) const state = getDemoScenarioState(demoScenario())
setPendingMembers([...demoPendingMembers]) setDashboard(state.dashboard)
setAdminSettings(demoAdminSettings) setPendingMembers(state.pendingMembers)
setCycleState(demoCycleState) setAdminSettings(state.adminSettings)
setCycleState(state.cycleState)
}
function setDemoScenario(value: DemoScenarioId) {
setDemoScenarioSignal(value)
if (readySession()?.mode === 'demo') {
const state = getDemoScenarioState(value)
setDashboard(state.dashboard)
setPendingMembers(state.pendingMembers)
setAdminSettings(state.adminSettings)
setCycleState(state.cycleState)
}
} }
return ( return (
@@ -424,6 +434,8 @@ export function DashboardProvider(props: ParentProps) {
memberUtilityBalanceVisuals, memberUtilityBalanceVisuals,
testingRolePreview, testingRolePreview,
setTestingRolePreview, setTestingRolePreview,
demoScenario,
setDemoScenario,
testingPeriodOverride, testingPeriodOverride,
setTestingPeriodOverride, setTestingPeriodOverride,
testingTodayOverride, testingTodayOverride,

View File

@@ -6,6 +6,15 @@ import type {
MiniAppSession MiniAppSession
} from '../miniapp-api' } from '../miniapp-api'
export type DemoScenarioId = 'current-cycle' | 'overdue-utilities' | 'overdue-rent-and-utilities'
type DemoScenarioState = {
dashboard: MiniAppDashboard
pendingMembers: readonly MiniAppPendingMember[]
adminSettings: MiniAppAdminSettingsPayload
cycleState: MiniAppAdminCycleState
}
export const demoMember: NonNullable<MiniAppSession['member']> = { export const demoMember: NonNullable<MiniAppSession['member']> = {
id: 'demo-member', id: 'demo-member',
householdId: 'demo-household', householdId: 'demo-household',
@@ -23,178 +32,26 @@ export const demoTelegramUser: NonNullable<MiniAppSession['telegramUser']> = {
languageCode: 'en' languageCode: 'en'
} }
export const demoDashboard: MiniAppDashboard = { const rentPaymentDestinations = [
period: '2026-03', {
currency: 'GEL', label: 'Landlord TBC card',
timezone: 'Asia/Tbilisi', recipientName: 'Nana Beridze',
rentWarningDay: 17, bankName: 'TBC Bank',
rentDueDay: 20, account: '1234 5678 9012 3456',
utilitiesReminderDay: 3, note: 'Message: Kojori House rent',
utilitiesDueDay: 4, link: null
paymentBalanceAdjustmentPolicy: 'utilities', },
rentPaymentDestinations: [ {
{ label: 'USD fallback transfer',
label: 'TBC card', recipientName: 'Nana Beridze',
recipientName: 'Landlord', bankName: 'Bank of Georgia',
bankName: 'TBC Bank', account: 'GE29BG0000000123456789',
account: '1234 5678 9012 3456', note: 'Use only if GEL transfer is unavailable',
note: null, link: 'https://bank.example/rent'
link: null }
} ] as const
],
totalDueMajor: '2410.00',
totalPaidMajor: '650.00',
totalRemainingMajor: '1760.00',
rentSourceAmountMajor: '875.00',
rentSourceCurrency: 'USD',
rentDisplayAmountMajor: '2415.00',
rentFxRateMicros: '2760000',
rentFxEffectiveDate: '2026-03-17',
members: [
{
memberId: 'demo-member',
displayName: 'Stas',
predictedUtilityShareMajor: '78.00',
rentShareMajor: '603.75',
utilityShareMajor: '78.00',
purchaseOffsetMajor: '-66.00',
netDueMajor: '615.75',
paidMajor: '615.75',
remainingMajor: '0.00',
explanations: ['Weighted rent share', 'Custom purchase split credit']
},
{
memberId: 'member-chorb',
displayName: 'Chorbanaut',
predictedUtilityShareMajor: '78.00',
rentShareMajor: '603.75',
utilityShareMajor: '78.00',
purchaseOffsetMajor: '12.00',
netDueMajor: '693.75',
paidMajor: '0.00',
remainingMajor: '693.75',
explanations: ['Standard resident share']
},
{
memberId: 'member-el',
displayName: 'El',
predictedUtilityShareMajor: '0.00',
rentShareMajor: '1207.50',
utilityShareMajor: '0.00',
purchaseOffsetMajor: '54.00',
netDueMajor: '1261.50',
paidMajor: '34.25',
remainingMajor: '1227.25',
explanations: ['Away policy applied to utilities']
}
],
ledger: [
{
id: 'purchase-1',
kind: 'purchase',
title: 'Bought kitchen towels',
memberId: 'demo-member',
paymentKind: null,
amountMajor: '24.00',
currency: 'GEL',
displayAmountMajor: '24.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-04T11:00:00.000Z',
purchaseSplitMode: 'equal',
purchaseParticipants: [
{ memberId: 'demo-member', included: true, shareAmountMajor: null },
{ memberId: 'member-chorb', included: true, shareAmountMajor: null },
{ memberId: 'member-el', included: false, shareAmountMajor: null }
]
},
{
id: 'purchase-2',
kind: 'purchase',
title: 'Electric kettle',
memberId: 'member-chorb',
paymentKind: null,
amountMajor: '96.00',
currency: 'GEL',
displayAmountMajor: '96.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Chorbanaut',
occurredAt: '2026-03-08T16:20:00.000Z',
purchaseSplitMode: 'custom_amounts',
purchaseParticipants: [
{ memberId: 'demo-member', included: true, shareAmountMajor: '42.00' },
{ memberId: 'member-chorb', included: true, shareAmountMajor: '24.00' },
{ memberId: 'member-el', included: true, shareAmountMajor: '30.00' }
]
},
{
id: 'utility-1',
kind: 'utility',
title: 'Electricity',
memberId: null,
paymentKind: null,
amountMajor: '154.00',
currency: 'GEL',
displayAmountMajor: '154.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-09T12:00:00.000Z'
},
{
id: 'utility-2',
kind: 'utility',
title: 'Internet',
memberId: null,
paymentKind: null,
amountMajor: '80.00',
currency: 'GEL',
displayAmountMajor: '80.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-10T10:30:00.000Z'
},
{
id: 'payment-1',
kind: 'payment',
title: 'rent',
memberId: 'demo-member',
paymentKind: 'rent',
amountMajor: '615.75',
currency: 'GEL',
displayAmountMajor: '615.75',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-11T18:10:00.000Z'
},
{
id: 'payment-2',
kind: 'payment',
title: 'utilities',
memberId: 'member-el',
paymentKind: 'utilities',
amountMajor: '34.25',
currency: 'GEL',
displayAmountMajor: '34.25',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'El',
occurredAt: '2026-03-13T09:00:00.000Z'
}
]
}
export const demoPendingMembers: readonly MiniAppPendingMember[] = [ const pendingMembers: readonly MiniAppPendingMember[] = [
{ {
telegramUserId: '555777', telegramUserId: '555777',
displayName: 'Mia', displayName: 'Mia',
@@ -206,10 +63,16 @@ export const demoPendingMembers: readonly MiniAppPendingMember[] = [
displayName: 'Dima', displayName: 'Dima',
username: 'dima', username: 'dima',
languageCode: 'en' languageCode: 'en'
},
{
telegramUserId: '888111',
displayName: 'Nika',
username: 'nika_forest',
languageCode: 'en'
} }
] ]
export const demoAdminSettings: MiniAppAdminSettingsPayload = { const adminSettings: MiniAppAdminSettingsPayload = {
householdName: 'Kojori House', householdName: 'Kojori House',
settings: { settings: {
householdId: 'demo-household', householdId: 'demo-household',
@@ -222,11 +85,12 @@ export const demoAdminSettings: MiniAppAdminSettingsPayload = {
utilitiesDueDay: 4, utilitiesDueDay: 4,
utilitiesReminderDay: 3, utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
rentPaymentDestinations: demoDashboard.rentPaymentDestinations rentPaymentDestinations
}, },
assistantConfig: { assistantConfig: {
householdId: 'demo-household', householdId: 'demo-household',
assistantContext: 'The household is a house in Kojori with a backyard and pine forest nearby.', assistantContext:
'The household is a large shared house in Kojori with a backyard, a guest room, and a long-running purchase ledger.',
assistantTone: 'Playful but concise' assistantTone: 'Playful but concise'
}, },
topics: [ topics: [
@@ -252,12 +116,20 @@ export const demoAdminSettings: MiniAppAdminSettingsPayload = {
sortOrder: 1, sortOrder: 1,
isActive: true isActive: true
}, },
{
id: 'cat-water',
householdId: 'demo-household',
slug: 'water',
name: 'Water',
sortOrder: 2,
isActive: true
},
{ {
id: 'cat-gas', id: 'cat-gas',
householdId: 'demo-household', householdId: 'demo-household',
slug: 'gas', slug: 'gas',
name: 'Gas', name: 'Gas',
sortOrder: 2, sortOrder: 3,
isActive: false isActive: false
} }
], ],
@@ -281,7 +153,7 @@ export const demoAdminSettings: MiniAppAdminSettingsPayload = {
] ]
} }
export const demoCycleState: MiniAppAdminCycleState = { const cycleState: MiniAppAdminCycleState = {
cycle: { cycle: {
id: 'cycle-demo-2026-03', id: 'cycle-demo-2026-03',
period: '2026-03', period: '2026-03',
@@ -295,10 +167,10 @@ export const demoCycleState: MiniAppAdminCycleState = {
{ {
id: 'utility-bill-1', id: 'utility-bill-1',
billName: 'Electricity', billName: 'Electricity',
amountMinor: '15400', amountMinor: '16400',
currency: 'GEL', currency: 'GEL',
createdByMemberId: 'demo-member', createdByMemberId: 'demo-member',
createdAt: '2026-03-09T12:00:00.000Z' createdAt: '2026-03-02T09:15:00.000Z'
}, },
{ {
id: 'utility-bill-2', id: 'utility-bill-2',
@@ -306,7 +178,487 @@ export const demoCycleState: MiniAppAdminCycleState = {
amountMinor: '8000', amountMinor: '8000',
currency: 'GEL', currency: 'GEL',
createdByMemberId: 'demo-member', createdByMemberId: 'demo-member',
createdAt: '2026-03-10T10:30:00.000Z' createdAt: '2026-03-03T10:30:00.000Z'
},
{
id: 'utility-bill-3',
billName: 'Water',
amountMinor: '4200',
currency: 'GEL',
createdByMemberId: 'member-chorb',
createdAt: '2026-03-03T12:45:00.000Z'
} }
] ]
} }
function baseLedger(): MiniAppDashboard['ledger'] {
return [
{
id: 'purchase-resolved-1',
kind: 'purchase',
title: 'Bulk cleaning supplies',
memberId: 'demo-member',
paymentKind: null,
amountMajor: '72.00',
currency: 'GEL',
displayAmountMajor: '72.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-01-28T18:30:00.000Z',
originPeriod: '2026-01',
resolutionStatus: 'resolved',
resolvedAt: '2026-02-04T09:10:00.000Z',
outstandingByMember: [],
payerMemberId: 'demo-member',
purchaseSplitMode: 'equal',
purchaseParticipants: [
{ memberId: 'demo-member', included: true, shareAmountMajor: null },
{ memberId: 'member-chorb', included: true, shareAmountMajor: null },
{ memberId: 'member-el', included: true, shareAmountMajor: null }
]
},
{
id: 'purchase-unresolved-1',
kind: 'purchase',
title: 'Gas heater refill',
memberId: 'member-chorb',
paymentKind: null,
amountMajor: '54.00',
currency: 'GEL',
displayAmountMajor: '54.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Chorbanaut',
occurredAt: '2026-02-17T20:15:00.000Z',
originPeriod: '2026-02',
resolutionStatus: 'unresolved',
resolvedAt: null,
outstandingByMember: [
{ memberId: 'demo-member', amountMajor: '18.00' },
{ memberId: 'member-el', amountMajor: '18.00' }
],
payerMemberId: 'member-chorb',
purchaseSplitMode: 'equal',
purchaseParticipants: [
{ memberId: 'demo-member', included: true, shareAmountMajor: null },
{ memberId: 'member-chorb', included: true, shareAmountMajor: null },
{ memberId: 'member-el', included: true, shareAmountMajor: null }
]
},
{
id: 'purchase-unresolved-2',
kind: 'purchase',
title: 'Water filter cartridges',
memberId: 'demo-member',
paymentKind: null,
amountMajor: '96.00',
currency: 'GEL',
displayAmountMajor: '96.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-03T19:00:00.000Z',
originPeriod: '2026-03',
resolutionStatus: 'unresolved',
resolvedAt: null,
outstandingByMember: [
{ memberId: 'member-chorb', amountMajor: '24.00' },
{ memberId: 'member-el', amountMajor: '34.00' }
],
payerMemberId: 'demo-member',
purchaseSplitMode: 'custom_amounts',
purchaseParticipants: [
{ memberId: 'demo-member', included: true, shareAmountMajor: '38.00' },
{ memberId: 'member-chorb', included: true, shareAmountMajor: '24.00' },
{ memberId: 'member-el', included: true, shareAmountMajor: '34.00' }
]
},
{
id: 'utility-1',
kind: 'utility',
title: 'Electricity',
memberId: null,
paymentKind: null,
amountMajor: '164.00',
currency: 'GEL',
displayAmountMajor: '164.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-02T09:15:00.000Z'
},
{
id: 'utility-2',
kind: 'utility',
title: 'Internet',
memberId: null,
paymentKind: null,
amountMajor: '80.00',
currency: 'GEL',
displayAmountMajor: '80.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-03T10:30:00.000Z'
},
{
id: 'utility-3',
kind: 'utility',
title: 'Water',
memberId: null,
paymentKind: null,
amountMajor: '42.00',
currency: 'GEL',
displayAmountMajor: '42.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Chorbanaut',
occurredAt: '2026-03-03T12:45:00.000Z'
},
{
id: 'payment-rent-demo',
kind: 'payment',
title: 'rent',
memberId: 'demo-member',
paymentKind: 'rent',
amountMajor: '603.75',
currency: 'GEL',
displayAmountMajor: '603.75',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-18T18:10:00.000Z'
},
{
id: 'payment-utilities-demo',
kind: 'payment',
title: 'utilities',
memberId: 'demo-member',
paymentKind: 'utilities',
amountMajor: '58.00',
currency: 'GEL',
displayAmountMajor: '58.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-04T20:00:00.000Z'
},
{
id: 'payment-rent-el',
kind: 'payment',
title: 'rent',
memberId: 'member-el',
paymentKind: 'rent',
amountMajor: '377.00',
currency: 'GEL',
displayAmountMajor: '377.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'El',
occurredAt: '2026-03-21T08:20:00.000Z'
}
]
}
function createDashboard(state: {
totalDueMajor: string
totalPaidMajor: string
totalRemainingMajor: string
members: MiniAppDashboard['members']
ledger?: MiniAppDashboard['ledger']
}): MiniAppDashboard {
return {
period: '2026-03',
currency: 'GEL',
timezone: 'Asia/Tbilisi',
rentWarningDay: 17,
rentDueDay: 20,
utilitiesReminderDay: 3,
utilitiesDueDay: 4,
paymentBalanceAdjustmentPolicy: 'utilities',
rentPaymentDestinations,
totalDueMajor: state.totalDueMajor,
totalPaidMajor: state.totalPaidMajor,
totalRemainingMajor: state.totalRemainingMajor,
rentSourceAmountMajor: '875.00',
rentSourceCurrency: 'USD',
rentDisplayAmountMajor: '2415.00',
rentFxRateMicros: '2760000',
rentFxEffectiveDate: '2026-03-17',
members: state.members,
ledger: state.ledger ?? baseLedger()
}
}
const demoScenarioCatalog: Record<DemoScenarioId, DemoScenarioState> = {
'current-cycle': {
dashboard: createDashboard({
totalDueMajor: '2571.00',
totalPaidMajor: '1288.75',
totalRemainingMajor: '1282.25',
members: [
{
memberId: 'demo-member',
displayName: 'Stas',
predictedUtilityShareMajor: '95.33',
rentShareMajor: '603.75',
utilityShareMajor: '95.33',
purchaseOffsetMajor: '-37.33',
netDueMajor: '661.75',
paidMajor: '661.75',
remainingMajor: '0.00',
overduePayments: [],
explanations: [
'Weighted rent share',
'Utilities reflect three posted bills',
'Purchase credit from January supplies and March water filters'
]
},
{
memberId: 'member-chorb',
displayName: 'Chorbanaut',
predictedUtilityShareMajor: '95.33',
rentShareMajor: '603.75',
utilityShareMajor: '95.33',
purchaseOffsetMajor: '44.67',
netDueMajor: '743.75',
paidMajor: '250.00',
remainingMajor: '493.75',
overduePayments: [],
explanations: [
'Standard resident share',
'Still owes current-cycle utilities and purchases'
]
},
{
memberId: 'member-el',
displayName: 'El',
predictedUtilityShareMajor: '0.00',
rentShareMajor: '1207.50',
utilityShareMajor: '0.00',
purchaseOffsetMajor: '-42.00',
netDueMajor: '1165.50',
paidMajor: '377.00',
remainingMajor: '788.50',
overduePayments: [],
explanations: ['Away policy applied to utilities', 'Purchase credit offsets part of rent']
}
]
}),
pendingMembers,
adminSettings,
cycleState
},
'overdue-utilities': {
dashboard: createDashboard({
totalDueMajor: '2623.00',
totalPaidMajor: '783.75',
totalRemainingMajor: '1839.25',
members: [
{
memberId: 'demo-member',
displayName: 'Stas',
predictedUtilityShareMajor: '104.00',
rentShareMajor: '603.75',
utilityShareMajor: '104.00',
purchaseOffsetMajor: '18.00',
netDueMajor: '725.75',
paidMajor: '603.75',
remainingMajor: '122.00',
overduePayments: [
{
kind: 'utilities',
amountMajor: '182.00',
periods: ['2026-01', '2026-02']
}
],
explanations: [
'Current rent is paid',
'Utilities remain overdue from two prior periods',
'Purchase carry-over stays separate from overdue closure'
]
},
{
memberId: 'member-chorb',
displayName: 'Chorbanaut',
predictedUtilityShareMajor: '104.00',
rentShareMajor: '603.75',
utilityShareMajor: '104.00',
purchaseOffsetMajor: '12.00',
netDueMajor: '719.75',
paidMajor: '180.00',
remainingMajor: '539.75',
overduePayments: [
{
kind: 'utilities',
amountMajor: '91.00',
periods: ['2026-02']
}
],
explanations: ['Partial utilities payment recorded this month']
},
{
memberId: 'member-el',
displayName: 'El',
predictedUtilityShareMajor: '0.00',
rentShareMajor: '1207.50',
utilityShareMajor: '0.00',
purchaseOffsetMajor: '-30.00',
netDueMajor: '1177.50',
paidMajor: '0.00',
remainingMajor: '1177.50',
overduePayments: [],
explanations: [
'Away policy applied to utilities',
'No overdue utility base because away policy removed the share'
]
}
],
ledger: [
...baseLedger(),
{
id: 'payment-overdue-utilities-jan',
kind: 'payment',
title: 'utilities',
memberId: 'member-chorb',
paymentKind: 'utilities',
amountMajor: '52.00',
currency: 'GEL',
displayAmountMajor: '52.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Chorbanaut',
occurredAt: '2026-02-07T21:10:00.000Z'
}
]
}),
pendingMembers,
adminSettings,
cycleState
},
'overdue-rent-and-utilities': {
dashboard: createDashboard({
totalDueMajor: '2629.00',
totalPaidMajor: '200.00',
totalRemainingMajor: '2429.00',
members: [
{
memberId: 'demo-member',
displayName: 'Stas',
predictedUtilityShareMajor: '88.00',
rentShareMajor: '603.75',
utilityShareMajor: '88.00',
purchaseOffsetMajor: '14.00',
netDueMajor: '705.75',
paidMajor: '0.00',
remainingMajor: '705.75',
overduePayments: [
{
kind: 'rent',
amountMajor: '603.75',
periods: ['2026-02']
},
{
kind: 'utilities',
amountMajor: '166.00',
periods: ['2026-01', '2026-02']
}
],
explanations: [
'Both rent and utilities are overdue',
'Current-cycle purchases remain visible but do not keep overdue open'
]
},
{
memberId: 'member-chorb',
displayName: 'Chorbanaut',
predictedUtilityShareMajor: '88.00',
rentShareMajor: '603.75',
utilityShareMajor: '88.00',
purchaseOffsetMajor: '36.00',
netDueMajor: '727.75',
paidMajor: '0.00',
remainingMajor: '727.75',
overduePayments: [
{
kind: 'rent',
amountMajor: '603.75',
periods: ['2026-02']
},
{
kind: 'utilities',
amountMajor: '88.00',
periods: ['2026-02']
}
],
explanations: ['No backfilled payments have been entered yet']
},
{
memberId: 'member-el',
displayName: 'El',
predictedUtilityShareMajor: '0.00',
rentShareMajor: '1207.50',
utilityShareMajor: '0.00',
purchaseOffsetMajor: '-12.00',
netDueMajor: '1195.50',
paidMajor: '200.00',
remainingMajor: '995.50',
overduePayments: [
{
kind: 'rent',
amountMajor: '1207.50',
periods: ['2026-02']
}
],
explanations: [
'Away policy still charges rent',
'One partial rent payment was entered late'
]
}
],
ledger: [
...baseLedger(),
{
id: 'payment-overdue-rent-el',
kind: 'payment',
title: 'rent',
memberId: 'member-el',
paymentKind: 'rent',
amountMajor: '200.00',
currency: 'GEL',
displayAmountMajor: '200.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'El',
occurredAt: '2026-02-23T14:40:00.000Z'
}
]
}),
pendingMembers,
adminSettings,
cycleState
}
}
export function getDemoScenarioState(id: DemoScenarioId): DemoScenarioState {
return structuredClone(demoScenarioCatalog[id])
}
const defaultScenarioState = getDemoScenarioState('current-cycle')
export const demoDashboard = defaultScenarioState.dashboard
export const demoPendingMembers = defaultScenarioState.pendingMembers
export const demoAdminSettings = defaultScenarioState.adminSettings
export const demoCycleState = defaultScenarioState.cycleState

View File

@@ -67,6 +67,9 @@ export const dictionary = {
homeUtilitiesTitle: 'Utilities payment', homeUtilitiesTitle: 'Utilities payment',
homeRentTitle: 'Rent payment', homeRentTitle: 'Rent payment',
homeNoPaymentTitle: 'No payment period', homeNoPaymentTitle: 'No payment period',
homeOverdueRentTitle: 'Overdue rent',
homeOverdueUtilitiesTitle: 'Overdue utilities',
homeOverduePeriodsLabel: 'Overdue periods: {periods}',
homeUtilitiesUpcomingLabel: 'Utilities starts {date}', homeUtilitiesUpcomingLabel: 'Utilities starts {date}',
homeRentUpcomingLabel: 'Rent starts {date}', homeRentUpcomingLabel: 'Rent starts {date}',
homeFillUtilitiesTitle: 'Fill utilities', homeFillUtilitiesTitle: 'Fill utilities',
@@ -139,7 +142,7 @@ export const dictionary = {
shareRent: 'Rent', shareRent: 'Rent',
shareUtilities: 'Utilities', shareUtilities: 'Utilities',
shareOffset: 'Shared buys', shareOffset: 'Shared buys',
rentFxTitle: 'House rent FX', rentFxTitle: 'Rent exchange rate',
sourceAmountLabel: 'Source', sourceAmountLabel: 'Source',
settlementAmountLabel: 'Settlement', settlementAmountLabel: 'Settlement',
fxEffectiveDateLabel: 'Locked', fxEffectiveDateLabel: 'Locked',
@@ -158,6 +161,17 @@ export const dictionary = {
testingPreviewResidentAction: 'Preview resident', testingPreviewResidentAction: 'Preview resident',
testingCurrentRoleLabel: 'Real access', testingCurrentRoleLabel: 'Real access',
testingPreviewRoleLabel: 'Previewing', testingPreviewRoleLabel: 'Previewing',
testingScenarioLabel: 'Demo scenario',
testingScenarioCurrentCycle: 'Current cycle',
testingScenarioCurrentCycleBody:
'Balanced current-period data with resolved and unresolved purchases, current utility bills, and partial payments from other members.',
testingScenarioOverdueUtilities: 'Overdue utilities',
testingScenarioOverdueUtilitiesBody:
'Shows utility overdue cards, current-cycle utility debt, and purchase carry-over that should survive after overdue closes.',
testingScenarioOverdueBoth: 'Overdue rent + utilities',
testingScenarioOverdueBothBody:
'Shows both overdue cards at once so you can test oldest-first payment routing and admin backfill flows.',
testingResetDemoStateAction: 'Reset demo state',
testingPeriodCurrentLabel: 'Dashboard period', testingPeriodCurrentLabel: 'Dashboard period',
testingPeriodOverrideLabel: 'Period override', testingPeriodOverrideLabel: 'Period override',
testingPeriodOverridePlaceholder: 'YYYY-MM', testingPeriodOverridePlaceholder: 'YYYY-MM',
@@ -184,6 +198,8 @@ export const dictionary = {
copiedToast: 'Copied!', copiedToast: 'Copied!',
quickPaymentTitle: 'Record payment', quickPaymentTitle: 'Record payment',
quickPaymentBody: 'Quickly record a {type} payment for the current cycle.', quickPaymentBody: 'Quickly record a {type} payment for the current cycle.',
quickPaymentCurrentBody: 'Quickly record a {type} payment for the current cycle.',
quickPaymentOverdueBody: 'Quickly record a {type} payment for overdue periods.',
quickPaymentAmountLabel: 'Amount', quickPaymentAmountLabel: 'Amount',
quickPaymentCurrencyLabel: 'Currency', quickPaymentCurrencyLabel: 'Currency',
quickPaymentSubmitAction: 'Save payment', quickPaymentSubmitAction: 'Save payment',
@@ -202,6 +218,10 @@ export const dictionary = {
purchaseSaveAction: 'Save purchase', purchaseSaveAction: 'Save purchase',
purchaseBalanceAction: 'Balance', purchaseBalanceAction: 'Balance',
purchaseRebalanceAction: 'Rebalance', purchaseRebalanceAction: 'Rebalance',
unresolvedPurchasesTitle: 'Outstanding purchases',
resolvedPurchasesTitle: 'Settled purchases',
unresolvedPurchasesEmpty: 'No unresolved purchases.',
resolvedPurchasesEmpty: 'No resolved purchases yet.',
purchaseDeleteAction: 'Delete', purchaseDeleteAction: 'Delete',
deletingPurchase: 'Deleting purchase…', deletingPurchase: 'Deleting purchase…',
savingPurchase: 'Saving purchase…', savingPurchase: 'Saving purchase…',
@@ -320,6 +340,9 @@ export const dictionary = {
saveDisplayName: 'Save name', saveDisplayName: 'Save name',
savingDisplayName: 'Saving name…', savingDisplayName: 'Saving name…',
memberStatusLabel: 'Member status', memberStatusLabel: 'Member status',
memberRoleLabel: 'Role',
memberRoleResident: 'Resident',
memberRoleAdmin: 'Admin',
saveMemberStatusAction: 'Save status', saveMemberStatusAction: 'Save status',
savingMemberStatus: 'Saving status…', savingMemberStatus: 'Saving status…',
memberStatusActive: 'Active', memberStatusActive: 'Active',
@@ -341,6 +364,8 @@ export const dictionary = {
promoteAdminAction: 'Promote to admin', promoteAdminAction: 'Promote to admin',
promoteAdminLabel: 'Admin access', promoteAdminLabel: 'Admin access',
promotingAdmin: 'Promoting…', promotingAdmin: 'Promoting…',
demoteAdminAction: 'Remove admin access',
demotingAdmin: 'Removing…',
residentHouseTitle: 'Household access', residentHouseTitle: 'Household access',
residentHouseBody: residentHouseBody:
'Your admins manage household settings and approvals here. You can still switch your own language above.', 'Your admins manage household settings and approvals here. You can still switch your own language above.',
@@ -422,6 +447,9 @@ export const dictionary = {
homeUtilitiesTitle: 'Оплата коммуналки', homeUtilitiesTitle: 'Оплата коммуналки',
homeRentTitle: 'Оплата аренды', homeRentTitle: 'Оплата аренды',
homeNoPaymentTitle: 'Период без оплаты', homeNoPaymentTitle: 'Период без оплаты',
homeOverdueRentTitle: 'Просроченная аренда',
homeOverdueUtilitiesTitle: 'Просроченная коммуналка',
homeOverduePeriodsLabel: 'Просроченные периоды: {periods}',
homeUtilitiesUpcomingLabel: 'Коммуналка с {date}', homeUtilitiesUpcomingLabel: 'Коммуналка с {date}',
homeRentUpcomingLabel: 'Аренда с {date}', homeRentUpcomingLabel: 'Аренда с {date}',
homeFillUtilitiesTitle: 'Внести коммуналку', homeFillUtilitiesTitle: 'Внести коммуналку',
@@ -494,7 +522,7 @@ export const dictionary = {
shareRent: 'Аренда', shareRent: 'Аренда',
shareUtilities: 'Коммуналка', shareUtilities: 'Коммуналка',
shareOffset: 'Общие покупки', shareOffset: 'Общие покупки',
rentFxTitle: 'FX по аренде дома', rentFxTitle: 'Курс для аренды',
sourceAmountLabel: 'Исходник', sourceAmountLabel: 'Исходник',
settlementAmountLabel: 'Расчёт', settlementAmountLabel: 'Расчёт',
fxEffectiveDateLabel: 'Зафиксировано', fxEffectiveDateLabel: 'Зафиксировано',
@@ -513,6 +541,17 @@ export const dictionary = {
testingPreviewResidentAction: 'Вид жителя', testingPreviewResidentAction: 'Вид жителя',
testingCurrentRoleLabel: 'Реальный доступ', testingCurrentRoleLabel: 'Реальный доступ',
testingPreviewRoleLabel: 'Сейчас показан', testingPreviewRoleLabel: 'Сейчас показан',
testingScenarioLabel: 'Демо-сценарий',
testingScenarioCurrentCycle: 'Текущий цикл',
testingScenarioCurrentCycleBody:
'Сбалансированный текущий период: есть закрытые и незакрытые покупки, актуальные коммунальные счета и частичные оплаты от других участников.',
testingScenarioOverdueUtilities: 'Просроченная коммуналка',
testingScenarioOverdueUtilitiesBody:
'Показывает карточку просроченной коммуналки, долг текущего цикла и перенос покупок, который должен остаться после закрытия просрочки.',
testingScenarioOverdueBoth: 'Просрочены аренда и коммуналка',
testingScenarioOverdueBothBody:
'Показывает обе просроченные карточки сразу, чтобы можно было проверить oldest-first распределение оплат и админский ввод задним числом.',
testingResetDemoStateAction: 'Сбросить демо-данные',
testingPeriodCurrentLabel: 'Период (из API)', testingPeriodCurrentLabel: 'Период (из API)',
testingPeriodOverrideLabel: 'Переопределить период', testingPeriodOverrideLabel: 'Переопределить период',
testingPeriodOverridePlaceholder: 'YYYY-MM', testingPeriodOverridePlaceholder: 'YYYY-MM',
@@ -541,6 +580,8 @@ export const dictionary = {
copiedToast: 'Скопировано!', copiedToast: 'Скопировано!',
quickPaymentTitle: 'Записать оплату', quickPaymentTitle: 'Записать оплату',
quickPaymentBody: 'Быстро запиши оплату {type} за текущий цикл.', quickPaymentBody: 'Быстро запиши оплату {type} за текущий цикл.',
quickPaymentCurrentBody: 'Быстро запиши оплату {type} за текущий цикл.',
quickPaymentOverdueBody: 'Быстро запиши оплату {type} за просроченные периоды.',
quickPaymentAmountLabel: 'Сумма', quickPaymentAmountLabel: 'Сумма',
quickPaymentCurrencyLabel: 'Валюта', quickPaymentCurrencyLabel: 'Валюта',
quickPaymentSubmitAction: 'Сохранить оплату', quickPaymentSubmitAction: 'Сохранить оплату',
@@ -559,6 +600,10 @@ export const dictionary = {
purchaseSaveAction: 'Сохранить покупку', purchaseSaveAction: 'Сохранить покупку',
purchaseBalanceAction: 'Сбалансировать', purchaseBalanceAction: 'Сбалансировать',
purchaseRebalanceAction: 'Перераспределить', purchaseRebalanceAction: 'Перераспределить',
unresolvedPurchasesTitle: 'Незакрытые покупки',
resolvedPurchasesTitle: 'Закрытые покупки',
unresolvedPurchasesEmpty: 'Незакрытых покупок нет.',
resolvedPurchasesEmpty: 'Закрытых покупок пока нет.',
purchaseDeleteAction: 'Удалить', purchaseDeleteAction: 'Удалить',
deletingPurchase: 'Удаляем покупку…', deletingPurchase: 'Удаляем покупку…',
savingPurchase: 'Сохраняем покупку…', savingPurchase: 'Сохраняем покупку…',
@@ -678,6 +723,9 @@ export const dictionary = {
saveDisplayName: 'Сохранить имя', saveDisplayName: 'Сохранить имя',
savingDisplayName: 'Сохраняем имя…', savingDisplayName: 'Сохраняем имя…',
memberStatusLabel: 'Статус участника', memberStatusLabel: 'Статус участника',
memberRoleLabel: 'Роль',
memberRoleResident: 'Житель',
memberRoleAdmin: 'Админ',
saveMemberStatusAction: 'Сохранить статус', saveMemberStatusAction: 'Сохранить статус',
savingMemberStatus: 'Сохраняем статус…', savingMemberStatus: 'Сохраняем статус…',
memberStatusActive: 'Активный', memberStatusActive: 'Активный',
@@ -699,6 +747,8 @@ export const dictionary = {
promoteAdminAction: 'Сделать админом', promoteAdminAction: 'Сделать админом',
promoteAdminLabel: 'Доступ админа', promoteAdminLabel: 'Доступ админа',
promotingAdmin: 'Повышаем…', promotingAdmin: 'Повышаем…',
demoteAdminAction: 'Убрать доступ админа',
demotingAdmin: 'Убираем…',
residentHouseTitle: 'Доступ к дому', residentHouseTitle: 'Доступ к дому',
residentHouseBody: residentHouseBody:
'Настройками дома и подтверждением заявок управляют админы. Свой язык можно менять переключателем выше.', 'Настройками дома и подтверждением заявок управляют админы. Свой язык можно менять переключателем выше.',

View File

@@ -58,6 +58,13 @@ a {
border-width: 0; border-width: 0;
} }
.ui-icon {
width: 18px;
height: 18px;
display: block;
flex-shrink: 0;
}
.empty-state { .empty-state {
color: var(--text-muted); color: var(--text-muted);
font-size: var(--text-sm); font-size: var(--text-sm);
@@ -822,9 +829,9 @@ a {
} }
.modal-sheet { .modal-sheet {
width: 100%; width: min(100%, 480px);
max-width: 480px; max-width: 480px;
max-height: 85dvh; max-height: min(92dvh, 900px);
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-xl) var(--radius-xl) 0 0; border-radius: var(--radius-xl) var(--radius-xl) 0 0;
@@ -848,6 +855,7 @@ a {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
gap: var(--spacing-md);
padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-md); padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-md);
position: sticky; position: sticky;
top: 0; top: 0;
@@ -869,6 +877,17 @@ a {
.modal-close-button { .modal-close-button {
flex-shrink: 0; flex-shrink: 0;
width: 40px;
min-width: 40px;
min-height: 40px;
color: var(--text-primary);
background: var(--bg-root);
border-color: var(--border);
}
.modal-close-button:hover:not(:disabled) {
background: var(--bg-elevated);
border-color: var(--border-hover);
} }
.modal-sheet__body { .modal-sheet__body {
@@ -887,6 +906,7 @@ a {
display: flex; display: flex;
gap: var(--spacing-sm); gap: var(--spacing-sm);
justify-content: flex-end; justify-content: flex-end;
flex-wrap: wrap;
} }
.modal-action-row--single { .modal-action-row--single {
@@ -1677,6 +1697,8 @@ a {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: var(--spacing-sm);
flex-wrap: wrap;
padding: var(--spacing-sm); padding: var(--spacing-sm);
background: var(--bg-root); background: var(--bg-root);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -1687,11 +1709,67 @@ a {
color: var(--text-muted); color: var(--text-muted);
} }
.testing-card__section strong {
margin-left: auto;
text-align: right;
}
.testing-card__section--stack {
align-items: flex-start;
}
.testing-card__section--stack strong {
margin-left: 0;
text-align: left;
}
.testing-card__section-content {
flex: 1 1 220px;
min-width: 0;
}
.testing-card__section-description {
margin-top: 4px;
color: var(--text-secondary);
line-height: 1.45;
}
.testing-card__actions { .testing-card__actions {
display: flex; display: flex;
gap: var(--spacing-sm); gap: var(--spacing-sm);
} }
.testing-card__actions--wrap {
flex-wrap: wrap;
}
.testing-card__actions--wrap .ui-button {
flex: 1 1 160px;
}
.testing-card__actions--stack {
flex-direction: column;
gap: 12px;
}
@media (max-width: 480px) {
.modal-sheet {
width: calc(100% - 16px);
}
.modal-sheet__header,
.modal-sheet__body,
.modal-sheet__footer {
padding-left: var(--spacing-lg);
padding-right: var(--spacing-lg);
}
.testing-card__actions .ui-button,
.modal-action-row .ui-button {
flex: 1 1 100%;
}
}
/* ── Balance Item (legacy compat) ─────────────────────── */ /* ── Balance Item (legacy compat) ─────────────────────── */
.balance-item { .balance-item {

View File

@@ -44,6 +44,7 @@ export type PaymentDraft = {
kind: 'rent' | 'utilities' kind: 'rent' | 'utilities'
amountMajor: string amountMajor: string
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
period: string
} }
/* ── Pure helpers ───────────────────────────────────── */ /* ── Pure helpers ───────────────────────────────────── */
@@ -170,7 +171,8 @@ export function paymentDrafts(
memberId: entry.memberId ?? '', memberId: entry.memberId ?? '',
kind: entry.paymentKind ?? 'rent', kind: entry.paymentKind ?? 'rent',
amountMajor: entry.amountMajor, amountMajor: entry.amountMajor,
currency: entry.currency currency: entry.currency,
period: ''
} }
]) ])
) )
@@ -181,7 +183,8 @@ export function paymentDraftForEntry(entry: MiniAppDashboard['ledger'][number]):
memberId: entry.memberId ?? '', memberId: entry.memberId ?? '',
kind: entry.paymentKind ?? 'rent', kind: entry.paymentKind ?? 'rent',
amountMajor: entry.amountMajor, amountMajor: entry.amountMajor,
currency: entry.currency currency: entry.currency,
period: ''
} }
} }

View File

@@ -130,6 +130,11 @@ export interface MiniAppDashboard {
netDueMajor: string netDueMajor: string
paidMajor: string paidMajor: string
remainingMajor: string remainingMajor: string
overduePayments: readonly {
kind: 'rent' | 'utilities'
amountMajor: string
periods: readonly string[]
}[]
explanations: readonly string[] explanations: readonly string[]
}[] }[]
ledger: { ledger: {
@@ -147,6 +152,13 @@ export interface MiniAppDashboard {
actorDisplayName: string | null actorDisplayName: string | null
occurredAt: string | null occurredAt: string | null
purchaseSplitMode?: 'equal' | 'custom_amounts' purchaseSplitMode?: 'equal' | 'custom_amounts'
originPeriod?: string | null
resolutionStatus?: 'unresolved' | 'resolved'
resolvedAt?: string | null
outstandingByMember?: readonly {
memberId: string
amountMajor: string
}[]
purchaseParticipants?: readonly { purchaseParticipants?: readonly {
memberId: string memberId: string
included: boolean included: boolean
@@ -711,6 +723,35 @@ export async function updateMiniAppMemberStatus(
return payload.member return payload.member
} }
export async function demoteMiniAppMember(
initData: string,
memberId: string
): Promise<MiniAppMember> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/members/demote`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
memberId
})
})
const payload = (await response.json()) as {
ok: boolean
authorized?: boolean
member?: MiniAppMember
error?: string
}
if (!response.ok || !payload.member) {
throw new Error(payload.error ?? 'Failed to remove admin access')
}
return payload.member
}
export async function updateMiniAppMemberAbsencePolicy( export async function updateMiniAppMemberAbsencePolicy(
initData: string, initData: string,
memberId: string, memberId: string,
@@ -1085,6 +1126,7 @@ export async function addMiniAppPayment(
kind: 'rent' | 'utilities' kind: 'rent' | 'utilities'
amountMajor: string amountMajor: string
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
period?: string
} }
): Promise<void> { ): Promise<void> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/payments/add`, { const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/payments/add`, {

View File

@@ -92,6 +92,9 @@ export default function HomeRoute() {
const [copiedValue, setCopiedValue] = createSignal<string | null>(null) const [copiedValue, setCopiedValue] = createSignal<string | null>(null)
const [quickPaymentOpen, setQuickPaymentOpen] = createSignal(false) const [quickPaymentOpen, setQuickPaymentOpen] = createSignal(false)
const [quickPaymentType, setQuickPaymentType] = createSignal<'rent' | 'utilities'>('rent') const [quickPaymentType, setQuickPaymentType] = createSignal<'rent' | 'utilities'>('rent')
const [quickPaymentContext, setQuickPaymentContext] = createSignal<'current' | 'overdue'>(
'current'
)
const [quickPaymentAmount, setQuickPaymentAmount] = createSignal('') const [quickPaymentAmount, setQuickPaymentAmount] = createSignal('')
const [submittingPayment, setSubmittingPayment] = createSignal(false) const [submittingPayment, setSubmittingPayment] = createSignal(false)
const [toastState, setToastState] = createSignal<{ const [toastState, setToastState] = createSignal<{
@@ -203,10 +206,10 @@ export default function HomeRoute() {
return override return override
}) })
const homeMode = createMemo(() => { const currentPaymentModes = createMemo(() => {
const data = dashboard() const data = dashboard()
const member = currentMemberLine() const member = currentMemberLine()
if (!data || !member) return 'none' as const if (!data || !member) return [] as ('rent' | 'utilities')[]
const period = effectivePeriod() ?? data.period const period = effectivePeriod() ?? data.period
const today = todayOverride() const today = todayOverride()
@@ -229,17 +232,21 @@ export default function HomeRoute() {
const utilitiesActive = utilities.active && utilitiesDueMinor > 0n const utilitiesActive = utilities.active && utilitiesDueMinor > 0n
const rentActive = rent.active && rentDueMinor > 0n const rentActive = rent.active && rentDueMinor > 0n
if (utilitiesActive && rentActive) { const modes: ('rent' | 'utilities')[] = []
const utilitiesDays = utilities.daysUntilDue ?? Number.POSITIVE_INFINITY if (utilitiesActive) {
const rentDays = rent.daysUntilDue ?? Number.POSITIVE_INFINITY modes.push('utilities')
return utilitiesDays <= rentDays ? ('utilities' as const) : ('rent' as const) }
if (rentActive) {
modes.push('rent')
} }
if (utilitiesActive) return 'utilities' as const return modes
if (rentActive) return 'rent' as const
return 'none' as const
}) })
function overduePaymentFor(kind: 'rent' | 'utilities') {
return currentMemberLine()?.overduePayments.find((payment) => payment.kind === kind) ?? null
}
async function handleSubmitUtilities() { async function handleSubmitUtilities() {
const data = initData() const data = initData()
const current = dashboard() const current = dashboard()
@@ -265,14 +272,21 @@ export default function HomeRoute() {
} }
} }
function openQuickPayment(type: 'rent' | 'utilities') { function openQuickPayment(
type: 'rent' | 'utilities',
context: 'current' | 'overdue' = 'current'
) {
const data = dashboard() const data = dashboard()
if (!data || !currentMemberLine()) return if (!data || !currentMemberLine()) return
const member = currentMemberLine()! const member = currentMemberLine()!
const amount = minorToMajorString(paymentRemainingMinor(data, member, type)) const amount =
context === 'overdue'
? (overduePaymentFor(type)?.amountMajor ?? '0.00')
: minorToMajorString(paymentRemainingMinor(data, member, type))
setQuickPaymentType(type) setQuickPaymentType(type)
setQuickPaymentContext(context)
setQuickPaymentAmount(amount) setQuickPaymentAmount(amount)
setQuickPaymentOpen(true) setQuickPaymentOpen(true)
} }
@@ -365,7 +379,7 @@ export default function HomeRoute() {
const utilitiesRemainingMinor = () => const utilitiesRemainingMinor = () =>
paymentRemainingMinor(data(), member(), 'utilities') paymentRemainingMinor(data(), member(), 'utilities')
const mode = () => homeMode() const modes = () => currentPaymentModes()
const currency = () => data().currency const currency = () => data().currency
const timezone = () => data().timezone const timezone = () => data().timezone
const period = () => effectivePeriod() ?? data().period const period = () => effectivePeriod() ?? data().period
@@ -431,7 +445,93 @@ export default function HomeRoute() {
return ( return (
<> <>
<Show when={mode() === 'utilities'}> <Show when={overduePaymentFor('utilities')}>
{(overdue) => (
<Card accent>
<div class="balance-card">
<div class="balance-card__header">
<span class="balance-card__label">
{copy().homeOverdueUtilitiesTitle}
</span>
<div
style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}
>
<Badge variant="danger">{copy().overdueLabel}</Badge>
<Button
variant="primary"
size="sm"
onClick={() => openQuickPayment('utilities', 'overdue')}
>
<CreditCard size={14} />
{copy().quickPaymentSubmitAction}
</Button>
</div>
</div>
<div class="balance-card__amounts">
<div class="balance-card__row balance-card__row--subtotal">
<span>{copy().finalDue}</span>
<strong>
{overdue().amountMajor} {currency()}
</strong>
</div>
<div class="balance-card__row">
<span>
{copy().homeOverduePeriodsLabel.replace(
'{periods}',
overdue().periods.join(', ')
)}
</span>
</div>
</div>
</div>
</Card>
)}
</Show>
<Show when={overduePaymentFor('rent')}>
{(overdue) => (
<Card accent>
<div class="balance-card">
<div class="balance-card__header">
<span class="balance-card__label">
{copy().homeOverdueRentTitle}
</span>
<div
style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}
>
<Badge variant="danger">{copy().overdueLabel}</Badge>
<Button
variant="primary"
size="sm"
onClick={() => openQuickPayment('rent', 'overdue')}
>
<CreditCard size={14} />
{copy().quickPaymentSubmitAction}
</Button>
</div>
</div>
<div class="balance-card__amounts">
<div class="balance-card__row balance-card__row--subtotal">
<span>{copy().finalDue}</span>
<strong>
{overdue().amountMajor} {currency()}
</strong>
</div>
<div class="balance-card__row">
<span>
{copy().homeOverduePeriodsLabel.replace(
'{periods}',
overdue().periods.join(', ')
)}
</span>
</div>
</div>
</div>
</Card>
)}
</Show>
<Show when={modes().includes('utilities')}>
<Card accent> <Card accent>
<div class="balance-card"> <div class="balance-card">
<div class="balance-card__header"> <div class="balance-card__header">
@@ -441,7 +541,7 @@ export default function HomeRoute() {
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
onClick={() => openQuickPayment('utilities')} onClick={() => openQuickPayment('utilities', 'current')}
> >
<CreditCard size={14} /> <CreditCard size={14} />
{copy().quickPaymentSubmitAction} {copy().quickPaymentSubmitAction}
@@ -496,7 +596,7 @@ export default function HomeRoute() {
</Card> </Card>
</Show> </Show>
<Show when={mode() === 'rent'}> <Show when={modes().includes('rent')}>
<Card accent> <Card accent>
<div class="balance-card"> <div class="balance-card">
<div class="balance-card__header"> <div class="balance-card__header">
@@ -506,7 +606,7 @@ export default function HomeRoute() {
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
onClick={() => openQuickPayment('rent')} onClick={() => openQuickPayment('rent', 'current')}
> >
<CreditCard size={14} /> <CreditCard size={14} />
{copy().quickPaymentSubmitAction} {copy().quickPaymentSubmitAction}
@@ -543,7 +643,13 @@ export default function HomeRoute() {
</Card> </Card>
</Show> </Show>
<Show when={mode() === 'none'}> <Show
when={
modes().length === 0 &&
!overduePaymentFor('utilities') &&
!overduePaymentFor('rent')
}
>
<Card muted> <Card muted>
<div class="balance-card"> <div class="balance-card">
<div class="balance-card__header"> <div class="balance-card__header">
@@ -587,7 +693,7 @@ export default function HomeRoute() {
</Card> </Card>
</Show> </Show>
<Show when={mode() === 'utilities' && utilityLedger().length === 0}> <Show when={modes().includes('utilities') && utilityLedger().length === 0}>
<Card> <Card>
<div class="balance-card"> <div class="balance-card">
<div class="balance-card__header"> <div class="balance-card__header">
@@ -643,7 +749,9 @@ export default function HomeRoute() {
</Card> </Card>
</Show> </Show>
<Show when={mode() === 'rent' && data().rentPaymentDestinations?.length}> <Show
when={modes().includes('rent') && data().rentPaymentDestinations?.length}
>
<div style={{ display: 'grid', gap: '12px' }}> <div style={{ display: 'grid', gap: '12px' }}>
<For each={data().rentPaymentDestinations ?? []}> <For each={data().rentPaymentDestinations ?? []}>
{(destination) => ( {(destination) => (
@@ -852,7 +960,10 @@ export default function HomeRoute() {
<Modal <Modal
open={quickPaymentOpen()} open={quickPaymentOpen()}
title={copy().quickPaymentTitle} title={copy().quickPaymentTitle}
description={copy().quickPaymentBody.replace( description={(quickPaymentContext() === 'overdue'
? copy().quickPaymentOverdueBody
: copy().quickPaymentCurrentBody
).replace(
'{type}', '{type}',
quickPaymentType() === 'rent' ? copy().shareRent : copy().shareUtilities quickPaymentType() === 'rent' ? copy().shareRent : copy().shareUtilities
)} )}

View File

@@ -206,6 +206,34 @@ export default function LedgerRoute() {
const { copy } = useI18n() const { copy } = useI18n()
const { dashboard, loading, effectiveIsAdmin, purchaseLedger, utilityLedger, paymentLedger } = const { dashboard, loading, effectiveIsAdmin, purchaseLedger, utilityLedger, paymentLedger } =
useDashboard() useDashboard()
const unresolvedPurchaseLedger = createMemo(() =>
purchaseLedger().filter((entry) => entry.resolutionStatus !== 'resolved')
)
const resolvedPurchaseLedger = createMemo(() =>
purchaseLedger().filter((entry) => entry.resolutionStatus === 'resolved')
)
const paymentPeriodOptions = createMemo(() => {
const periods = new Set<string>()
if (dashboard()?.period) {
periods.add(dashboard()!.period)
}
for (const entry of purchaseLedger()) {
if (entry.originPeriod) {
periods.add(entry.originPeriod)
}
}
for (const member of dashboard()?.members ?? []) {
for (const overdue of member.overduePayments) {
for (const period of overdue.periods) {
periods.add(period)
}
}
}
return [...periods].sort().map((period) => ({ value: period, label: period }))
})
// ── Purchase editor ────────────────────────────── // ── Purchase editor ──────────────────────────────
const [editingPurchase, setEditingPurchase] = createSignal< const [editingPurchase, setEditingPurchase] = createSignal<
@@ -262,7 +290,8 @@ export default function LedgerRoute() {
memberId: '', memberId: '',
kind: 'rent', kind: 'rent',
amountMajor: '', amountMajor: '',
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL' currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
period: dashboard()?.period ?? ''
}) })
const [addingPayment, setAddingPayment] = createSignal(false) const [addingPayment, setAddingPayment] = createSignal(false)
@@ -518,14 +547,16 @@ export default function LedgerRoute() {
memberId: draft.memberId, memberId: draft.memberId,
kind: draft.kind, kind: draft.kind,
amountMajor: draft.amountMajor, amountMajor: draft.amountMajor,
currency: draft.currency currency: draft.currency,
...(draft.period ? { period: draft.period } : {})
}) })
setAddPaymentOpen(false) setAddPaymentOpen(false)
setNewPayment({ setNewPayment({
memberId: '', memberId: '',
kind: 'rent', kind: 'rent',
amountMajor: '', amountMajor: '',
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL' currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
period: dashboard()?.period ?? ''
}) })
await refreshHouseholdData(true, true) await refreshHouseholdData(true, true)
} finally { } finally {
@@ -615,31 +646,84 @@ export default function LedgerRoute() {
when={purchaseLedger().length > 0} when={purchaseLedger().length > 0}
fallback={<p class="empty-state">{copy().purchasesEmpty}</p>} fallback={<p class="empty-state">{copy().purchasesEmpty}</p>}
> >
<div class="editable-list"> <div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
<For each={purchaseLedger()}> <div>
{(entry) => ( <strong>{copy().unresolvedPurchasesTitle}</strong>
<button <Show
class="editable-list-row" when={unresolvedPurchaseLedger().length > 0}
onClick={() => effectiveIsAdmin() && openPurchaseEditor(entry)} fallback={<p class="empty-state">{copy().unresolvedPurchasesEmpty}</p>}
disabled={!effectiveIsAdmin()} >
> <div class="editable-list">
<div class="editable-list-row__main"> <For each={unresolvedPurchaseLedger()}>
<span class="editable-list-row__title">{entry.title}</span> {(entry) => (
<span class="editable-list-row__subtitle"> <button
{entry.actorDisplayName} class="editable-list-row"
</span> onClick={() => effectiveIsAdmin() && openPurchaseEditor(entry)}
</div> disabled={!effectiveIsAdmin()}
<div class="editable-list-row__meta"> >
<strong>{ledgerPrimaryAmount(entry)}</strong> <div class="editable-list-row__main">
<Show when={ledgerSecondaryAmount(entry)}> <span class="editable-list-row__title">{entry.title}</span>
{(secondary) => ( <span class="editable-list-row__subtitle">
<span class="editable-list-row__secondary">{secondary()}</span> {[entry.actorDisplayName, entry.originPeriod, 'Unresolved']
)} .filter(Boolean)
</Show> .join(' · ')}
</div> </span>
</button> </div>
)} <div class="editable-list-row__meta">
</For> <strong>{ledgerPrimaryAmount(entry)}</strong>
<Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => (
<span class="editable-list-row__secondary">
{secondary()}
</span>
)}
</Show>
</div>
</button>
)}
</For>
</div>
</Show>
</div>
<div>
<strong>{copy().resolvedPurchasesTitle}</strong>
<Show
when={resolvedPurchaseLedger().length > 0}
fallback={<p class="empty-state">{copy().resolvedPurchasesEmpty}</p>}
>
<div class="editable-list">
<For each={resolvedPurchaseLedger()}>
{(entry) => (
<button
class="editable-list-row"
onClick={() => effectiveIsAdmin() && openPurchaseEditor(entry)}
disabled={!effectiveIsAdmin()}
>
<div class="editable-list-row__main">
<span class="editable-list-row__title">{entry.title}</span>
<span class="editable-list-row__subtitle">
{[entry.actorDisplayName, entry.originPeriod, entry.resolvedAt]
.filter(Boolean)
.join(' · ')}
</span>
</div>
<div class="editable-list-row__meta">
<strong>{ledgerPrimaryAmount(entry)}</strong>
<Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => (
<span class="editable-list-row__secondary">
{secondary()}
</span>
)}
</Show>
</div>
</button>
)}
</For>
</div>
</Show>
</div>
</div> </div>
</Show> </Show>
</Collapsible> </Collapsible>
@@ -691,7 +775,17 @@ export default function LedgerRoute() {
> >
<Show when={effectiveIsAdmin()}> <Show when={effectiveIsAdmin()}>
<div class="editable-list-actions"> <div class="editable-list-actions">
<Button variant="primary" size="sm" onClick={() => setAddPaymentOpen(true)}> <Button
variant="primary"
size="sm"
onClick={() => {
setNewPayment((payment) => ({
...payment,
period: dashboard()?.period ?? ''
}))
setAddPaymentOpen(true)
}}
>
<Plus size={14} /> <Plus size={14} />
{copy().paymentsAddAction} {copy().paymentsAddAction}
</Button> </Button>
@@ -1023,6 +1117,15 @@ export default function LedgerRoute() {
} }
/> />
</Field> </Field>
<Field label="Billing period">
<Select
value={newPayment().period ?? ''}
placeholder="—"
ariaLabel="Billing period"
options={[{ value: '', label: '—' }, ...paymentPeriodOptions()]}
onChange={(value) => setNewPayment((p) => ({ ...p, period: value }))}
/>
</Field>
<Field label={copy().paymentAmount}> <Field label={copy().paymentAmount}>
<Input <Input
type="number" type="number"

View File

@@ -18,6 +18,7 @@ import {
updateMiniAppMemberDisplayName, updateMiniAppMemberDisplayName,
updateMiniAppMemberRentWeight, updateMiniAppMemberRentWeight,
updateMiniAppMemberStatus, updateMiniAppMemberStatus,
demoteMiniAppMember,
promoteMiniAppMember, promoteMiniAppMember,
approveMiniAppPendingMember, approveMiniAppPendingMember,
rejectMiniAppPendingMember, rejectMiniAppPendingMember,
@@ -260,6 +261,10 @@ export default function SettingsRoute() {
if (form.isAdmin && !currentMember.isAdmin) { if (form.isAdmin && !currentMember.isAdmin) {
updatedMember = await promoteMiniAppMember(data, memberId) updatedMember = await promoteMiniAppMember(data, memberId)
} }
// Remove admin access if requested and currently admin
if (!form.isAdmin && currentMember.isAdmin) {
updatedMember = await demoteMiniAppMember(data, memberId)
}
// Update local state // Update local state
setAdminSettings((prev) => { setAdminSettings((prev) => {
@@ -906,16 +911,17 @@ export default function SettingsRoute() {
} }
/> />
</Field> </Field>
<Show when={!editMemberForm().isAdmin}> <Field label={copy().memberRoleLabel}>
<Field label={copy().promoteAdminLabel}> <Select
<Button value={editMemberForm().isAdmin ? 'admin' : 'resident'}
variant="secondary" ariaLabel={copy().memberRoleLabel}
onClick={() => setEditMemberForm((f) => ({ ...f, isAdmin: true }))} options={[
> { value: 'resident', label: copy().memberRoleResident },
{copy().promoteAdminAction} { value: 'admin', label: copy().memberRoleAdmin }
</Button> ]}
</Field> onChange={(value) => setEditMemberForm((f) => ({ ...f, isAdmin: value === 'admin' }))}
</Show> />
</Field>
</div> </div>
</Modal> </Modal>

View File

@@ -153,6 +153,23 @@ export function createDbFinanceRepository(
} }
}, },
async listCycles() {
const rows = await db
.select({
id: schema.billingCycles.id,
period: schema.billingCycles.period,
currency: schema.billingCycles.currency
})
.from(schema.billingCycles)
.where(eq(schema.billingCycles.householdId, householdId))
.orderBy(schema.billingCycles.period)
return rows.map((row) => ({
...row,
currency: toCurrencyCode(row.currency)
}))
},
async getCycleByPeriod(period) { async getCycleByPeriod(period) {
const rows = await db const rows = await db
.select({ .select({
@@ -354,6 +371,7 @@ export function createDbFinanceRepository(
await db.insert(schema.purchaseMessages).values({ await db.insert(schema.purchaseMessages).values({
id: purchaseId, id: purchaseId,
householdId, householdId,
cycleId: input.cycleId,
senderMemberId: input.payerMemberId, senderMemberId: input.payerMemberId,
payerMemberId: input.payerMemberId, payerMemberId: input.payerMemberId,
senderTelegramUserId: 'miniapp', senderTelegramUserId: 'miniapp',
@@ -415,11 +433,13 @@ export function createDbFinanceRepository(
return { return {
id: row.id, id: row.id,
cycleId: input.cycleId,
payerMemberId: row.payerMemberId, payerMemberId: row.payerMemberId,
amountMinor: row.amountMinor, amountMinor: row.amountMinor,
currency: toCurrencyCode(row.currency), currency: toCurrencyCode(row.currency),
description: row.description, description: row.description,
occurredAt: row.occurredAt ? instantFromDatabaseValue(row.occurredAt) : null, occurredAt: row.occurredAt ? instantFromDatabaseValue(row.occurredAt) : null,
cyclePeriod: null,
splitMode: row.splitMode as 'equal' | 'custom_amounts', splitMode: row.splitMode as 'equal' | 'custom_amounts',
participants: participantRows.map((p) => ({ participants: participantRows.map((p) => ({
memberId: p.memberId, memberId: p.memberId,
@@ -502,11 +522,13 @@ export function createDbFinanceRepository(
return { return {
id: row.id, id: row.id,
cycleId: null,
payerMemberId: row.payerMemberId, payerMemberId: row.payerMemberId,
amountMinor: row.amountMinor, amountMinor: row.amountMinor,
currency: toCurrencyCode(row.currency), currency: toCurrencyCode(row.currency),
description: row.description, description: row.description,
occurredAt: instantFromDatabaseValue(row.occurredAt), occurredAt: instantFromDatabaseValue(row.occurredAt),
cyclePeriod: null,
splitMode: row.splitMode === 'custom_amounts' ? 'custom_amounts' : 'equal', splitMode: row.splitMode === 'custom_amounts' ? 'custom_amounts' : 'equal',
participants: participants.map((participant) => ({ participants: participants.map((participant) => ({
id: participant.id, id: participant.id,
@@ -596,6 +618,7 @@ export function createDbFinanceRepository(
}) })
.returning({ .returning({
id: schema.paymentRecords.id, id: schema.paymentRecords.id,
cycleId: schema.paymentRecords.cycleId,
memberId: schema.paymentRecords.memberId, memberId: schema.paymentRecords.memberId,
kind: schema.paymentRecords.kind, kind: schema.paymentRecords.kind,
amountMinor: schema.paymentRecords.amountMinor, amountMinor: schema.paymentRecords.amountMinor,
@@ -610,6 +633,8 @@ export function createDbFinanceRepository(
return { return {
id: row.id, id: row.id,
cycleId: row.cycleId,
cyclePeriod: null,
memberId: row.memberId, memberId: row.memberId,
kind: row.kind === 'utilities' ? 'utilities' : 'rent', kind: row.kind === 'utilities' ? 'utilities' : 'rent',
amountMinor: row.amountMinor, amountMinor: row.amountMinor,
@@ -618,6 +643,66 @@ export function createDbFinanceRepository(
} }
}, },
async getPaymentRecord(paymentId) {
const rows = await db
.select({
id: schema.paymentRecords.id,
cycleId: schema.paymentRecords.cycleId,
cyclePeriod: schema.billingCycles.period,
memberId: schema.paymentRecords.memberId,
kind: schema.paymentRecords.kind,
amountMinor: schema.paymentRecords.amountMinor,
currency: schema.paymentRecords.currency,
recordedAt: schema.paymentRecords.recordedAt
})
.from(schema.paymentRecords)
.innerJoin(schema.billingCycles, eq(schema.paymentRecords.cycleId, schema.billingCycles.id))
.where(
and(
eq(schema.paymentRecords.householdId, householdId),
eq(schema.paymentRecords.id, paymentId)
)
)
.limit(1)
const row = rows[0]
if (!row) {
return null
}
return {
id: row.id,
cycleId: row.cycleId,
cyclePeriod: row.cyclePeriod,
memberId: row.memberId,
kind: row.kind === 'utilities' ? 'utilities' : 'rent',
amountMinor: row.amountMinor,
currency: toCurrencyCode(row.currency),
recordedAt: instantFromDatabaseValue(row.recordedAt)!
}
},
async replacePaymentPurchaseAllocations(input) {
await db.transaction(async (tx) => {
await tx
.delete(schema.paymentPurchaseAllocations)
.where(eq(schema.paymentPurchaseAllocations.paymentRecordId, input.paymentRecordId))
if (input.allocations.length === 0) {
return
}
await tx.insert(schema.paymentPurchaseAllocations).values(
input.allocations.map((allocation) => ({
paymentRecordId: input.paymentRecordId,
purchaseId: allocation.purchaseId,
memberId: allocation.memberId,
amountMinor: allocation.amountMinor
}))
)
})
},
async updatePaymentRecord(input) { async updatePaymentRecord(input) {
const rows = await db const rows = await db
.update(schema.paymentRecords) .update(schema.paymentRecords)
@@ -635,6 +720,7 @@ export function createDbFinanceRepository(
) )
.returning({ .returning({
id: schema.paymentRecords.id, id: schema.paymentRecords.id,
cycleId: schema.paymentRecords.cycleId,
memberId: schema.paymentRecords.memberId, memberId: schema.paymentRecords.memberId,
kind: schema.paymentRecords.kind, kind: schema.paymentRecords.kind,
amountMinor: schema.paymentRecords.amountMinor, amountMinor: schema.paymentRecords.amountMinor,
@@ -649,6 +735,8 @@ export function createDbFinanceRepository(
return { return {
id: row.id, id: row.id,
cycleId: row.cycleId,
cyclePeriod: null,
memberId: row.memberId, memberId: row.memberId,
kind: row.kind === 'utilities' ? 'utilities' : 'rent', kind: row.kind === 'utilities' ? 'utilities' : 'rent',
amountMinor: row.amountMinor, amountMinor: row.amountMinor,
@@ -741,6 +829,8 @@ export function createDbFinanceRepository(
const rows = await db const rows = await db
.select({ .select({
id: schema.paymentRecords.id, id: schema.paymentRecords.id,
cycleId: schema.paymentRecords.cycleId,
cyclePeriod: schema.billingCycles.period,
memberId: schema.paymentRecords.memberId, memberId: schema.paymentRecords.memberId,
kind: schema.paymentRecords.kind, kind: schema.paymentRecords.kind,
amountMinor: schema.paymentRecords.amountMinor, amountMinor: schema.paymentRecords.amountMinor,
@@ -748,11 +838,14 @@ export function createDbFinanceRepository(
recordedAt: schema.paymentRecords.recordedAt recordedAt: schema.paymentRecords.recordedAt
}) })
.from(schema.paymentRecords) .from(schema.paymentRecords)
.innerJoin(schema.billingCycles, eq(schema.paymentRecords.cycleId, schema.billingCycles.id))
.where(eq(schema.paymentRecords.cycleId, cycleId)) .where(eq(schema.paymentRecords.cycleId, cycleId))
.orderBy(schema.paymentRecords.recordedAt) .orderBy(schema.paymentRecords.recordedAt)
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
cycleId: row.cycleId,
cyclePeriod: row.cyclePeriod,
memberId: row.memberId, memberId: row.memberId,
kind: row.kind === 'utilities' ? 'utilities' : 'rent', kind: row.kind === 'utilities' ? 'utilities' : 'rent',
amountMinor: row.amountMinor, amountMinor: row.amountMinor,
@@ -765,6 +858,8 @@ export function createDbFinanceRepository(
const rows = await db const rows = await db
.select({ .select({
id: schema.purchaseMessages.id, id: schema.purchaseMessages.id,
cycleId: schema.purchaseMessages.cycleId,
cyclePeriod: schema.billingCycles.period,
payerMemberId: schema.purchaseMessages.payerMemberId, payerMemberId: schema.purchaseMessages.payerMemberId,
amountMinor: schema.purchaseMessages.parsedAmountMinor, amountMinor: schema.purchaseMessages.parsedAmountMinor,
currency: schema.purchaseMessages.parsedCurrency, currency: schema.purchaseMessages.parsedCurrency,
@@ -773,6 +868,10 @@ export function createDbFinanceRepository(
splitMode: schema.purchaseMessages.participantSplitMode splitMode: schema.purchaseMessages.participantSplitMode
}) })
.from(schema.purchaseMessages) .from(schema.purchaseMessages)
.leftJoin(
schema.billingCycles,
eq(schema.purchaseMessages.cycleId, schema.billingCycles.id)
)
.where( .where(
and( and(
eq(schema.purchaseMessages.householdId, householdId), eq(schema.purchaseMessages.householdId, householdId),
@@ -792,6 +891,8 @@ export function createDbFinanceRepository(
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
cycleId: row.cycleId,
cyclePeriod: row.cyclePeriod,
payerMemberId: row.payerMemberId!, payerMemberId: row.payerMemberId!,
amountMinor: row.amountMinor!, amountMinor: row.amountMinor!,
currency: toCurrencyCode(row.currency!), currency: toCurrencyCode(row.currency!),
@@ -802,6 +903,82 @@ export function createDbFinanceRepository(
})) }))
}, },
async listParsedPurchases() {
const rows = await db
.select({
id: schema.purchaseMessages.id,
cycleId: schema.purchaseMessages.cycleId,
cyclePeriod: schema.billingCycles.period,
payerMemberId: schema.purchaseMessages.payerMemberId,
amountMinor: schema.purchaseMessages.parsedAmountMinor,
currency: schema.purchaseMessages.parsedCurrency,
description: schema.purchaseMessages.parsedItemDescription,
occurredAt: schema.purchaseMessages.messageSentAt,
splitMode: schema.purchaseMessages.participantSplitMode
})
.from(schema.purchaseMessages)
.leftJoin(
schema.billingCycles,
eq(schema.purchaseMessages.cycleId, schema.billingCycles.id)
)
.where(
and(
eq(schema.purchaseMessages.householdId, householdId),
isNotNull(schema.purchaseMessages.payerMemberId),
isNotNull(schema.purchaseMessages.parsedAmountMinor),
isNotNull(schema.purchaseMessages.parsedCurrency),
or(
eq(schema.purchaseMessages.processingStatus, 'parsed'),
eq(schema.purchaseMessages.processingStatus, 'confirmed')
)
)
)
.orderBy(schema.purchaseMessages.messageSentAt, schema.purchaseMessages.id)
const participantsByPurchaseId = await loadPurchaseParticipants(rows.map((row) => row.id))
return rows.map((row) => ({
id: row.id,
cycleId: row.cycleId,
cyclePeriod: row.cyclePeriod,
payerMemberId: row.payerMemberId!,
amountMinor: row.amountMinor!,
currency: toCurrencyCode(row.currency!),
description: row.description,
occurredAt: instantFromDatabaseValue(row.occurredAt),
splitMode: row.splitMode === 'custom_amounts' ? 'custom_amounts' : 'equal',
participants: participantsByPurchaseId.get(row.id) ?? []
}))
},
async listPaymentPurchaseAllocations() {
const rows = await db
.select({
id: schema.paymentPurchaseAllocations.id,
paymentRecordId: schema.paymentPurchaseAllocations.paymentRecordId,
purchaseId: schema.paymentPurchaseAllocations.purchaseId,
memberId: schema.paymentPurchaseAllocations.memberId,
amountMinor: schema.paymentPurchaseAllocations.amountMinor,
recordedAt: schema.paymentRecords.recordedAt
})
.from(schema.paymentPurchaseAllocations)
.innerJoin(
schema.paymentRecords,
eq(schema.paymentPurchaseAllocations.paymentRecordId, schema.paymentRecords.id)
)
.where(eq(schema.paymentRecords.householdId, householdId))
.orderBy(
schema.paymentPurchaseAllocations.purchaseId,
schema.paymentPurchaseAllocations.memberId,
schema.paymentPurchaseAllocations.createdAt
)
return rows.map((row) => ({
...row,
recordedAt: instantFromDatabaseValue(row.recordedAt)!
}))
},
async getSettlementSnapshotLines(cycleId) { async getSettlementSnapshotLines(cycleId) {
const rows = await db const rows = await db
.select({ .select({
@@ -907,6 +1084,8 @@ export function createDbFinanceRepository(
status: 'recorded' as const, status: 'recorded' as const,
paymentRecord: { paymentRecord: {
id: paymentRow.id, id: paymentRow.id,
cycleId: input.cycleId,
cyclePeriod: null,
memberId: paymentRow.memberId, memberId: paymentRow.memberId,
kind: paymentRow.kind === 'utilities' ? 'utilities' : 'rent', kind: paymentRow.kind === 'utilities' ? 'utilities' : 'rent',
amountMinor: paymentRow.amountMinor, amountMinor: paymentRow.amountMinor,

View File

@@ -1512,6 +1512,40 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
}) })
}, },
async demoteHouseholdAdmin(householdId, memberId) {
const rows = await db
.update(schema.members)
.set({
isAdmin: 0
})
.where(and(eq(schema.members.householdId, householdId), eq(schema.members.id, memberId)))
.returning({
id: schema.members.id,
householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
lifecycleStatus: schema.members.lifecycleStatus,
preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin
})
const row = rows[0]
if (!row) {
return null
}
const household = await this.getHouseholdChatByHouseholdId(householdId)
if (!household) {
throw new Error('Failed to resolve household chat after household admin demotion')
}
return toHouseholdMemberRecord({
...row,
defaultLocale: household.defaultLocale
})
},
async updateHouseholdMemberRentShareWeight(householdId, memberId, rentShareWeight) { async updateHouseholdMemberRentShareWeight(householdId, memberId, rentShareWeight) {
const rows = await db const rows = await db
.update(schema.members) .update(schema.members)

View File

@@ -45,10 +45,12 @@ class FinanceRepositoryStub implements FinanceRepository {
openCycleRecord: FinanceCycleRecord | null = null openCycleRecord: FinanceCycleRecord | null = null
cycleByPeriodRecord: FinanceCycleRecord | null = null cycleByPeriodRecord: FinanceCycleRecord | null = null
latestCycleRecord: FinanceCycleRecord | null = null latestCycleRecord: FinanceCycleRecord | null = null
cycles: readonly FinanceCycleRecord[] = []
rentRule: FinanceRentRuleRecord | null = null rentRule: FinanceRentRuleRecord | null = null
purchases: readonly FinanceParsedPurchaseRecord[] = [] purchases: readonly FinanceParsedPurchaseRecord[] = []
utilityBills: readonly { utilityBills: readonly {
id: string id: string
cycleId?: string
billName: string billName: string
amountMinor: bigint amountMinor: bigint
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
@@ -57,6 +59,8 @@ class FinanceRepositoryStub implements FinanceRepository {
}[] = [] }[] = []
paymentRecords: readonly { paymentRecords: readonly {
id: string id: string
cycleId: string
cyclePeriod?: string | null
memberId: string memberId: string
kind: 'rent' | 'utilities' kind: 'rent' | 'utilities'
amountMinor: bigint amountMinor: bigint
@@ -79,6 +83,10 @@ class FinanceRepositoryStub implements FinanceRepository {
cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>() cycleExchangeRates = new Map<string, FinanceCycleExchangeRateRecord>()
lastUpdatedPurchaseInput: Parameters<FinanceRepository['updateParsedPurchase']>[0] | null = null lastUpdatedPurchaseInput: Parameters<FinanceRepository['updateParsedPurchase']>[0] | null = null
lastAddedPurchaseInput: Parameters<FinanceRepository['addParsedPurchase']>[0] | null = null lastAddedPurchaseInput: Parameters<FinanceRepository['addParsedPurchase']>[0] | null = null
lastReplacedPaymentPurchaseAllocations:
| Parameters<FinanceRepository['replacePaymentPurchaseAllocations']>[0]
| null = null
addedPaymentRecords: Parameters<FinanceRepository['addPaymentRecord']>[0][] = []
async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> { async getMemberByTelegramUserId(): Promise<FinanceMemberRecord | null> {
return this.member return this.member
@@ -88,12 +96,27 @@ class FinanceRepositoryStub implements FinanceRepository {
return this.members return this.members
} }
async listCycles(): Promise<readonly FinanceCycleRecord[]> {
if (this.cycles.length > 0) {
return this.cycles
}
return [this.openCycleRecord ?? this.cycleByPeriodRecord ?? this.latestCycleRecord].filter(
(cycle): cycle is FinanceCycleRecord => Boolean(cycle)
)
}
async getOpenCycle(): Promise<FinanceCycleRecord | null> { async getOpenCycle(): Promise<FinanceCycleRecord | null> {
return this.openCycleRecord return this.openCycleRecord
} }
async getCycleByPeriod(): Promise<FinanceCycleRecord | null> { async getCycleByPeriod(period: string): Promise<FinanceCycleRecord | null> {
return this.cycleByPeriodRecord ?? this.openCycleRecord ?? this.latestCycleRecord return (
this.cycles.find((cycle) => cycle.period === period) ??
(this.cycleByPeriodRecord?.period === period ? this.cycleByPeriodRecord : null) ??
(this.openCycleRecord?.period === period ? this.openCycleRecord : null) ??
(this.latestCycleRecord?.period === period ? this.latestCycleRecord : null)
)
} }
async getLatestCycle(): Promise<FinanceCycleRecord | null> { async getLatestCycle(): Promise<FinanceCycleRecord | null> {
@@ -153,6 +176,8 @@ class FinanceRepositoryStub implements FinanceRepository {
this.lastAddedPurchaseInput = input this.lastAddedPurchaseInput = input
return { return {
id: 'purchase-1', id: 'purchase-1',
cycleId: input.cycleId,
cyclePeriod: null,
payerMemberId: input.payerMemberId, payerMemberId: input.payerMemberId,
amountMinor: input.amountMinor, amountMinor: input.amountMinor,
currency: input.currency, currency: input.currency,
@@ -179,6 +204,8 @@ class FinanceRepositoryStub implements FinanceRepository {
this.lastUpdatedPurchaseInput = input this.lastUpdatedPurchaseInput = input
return { return {
id: input.purchaseId, id: input.purchaseId,
cycleId: null,
cyclePeriod: null,
payerMemberId: 'alice', payerMemberId: 'alice',
amountMinor: input.amountMinor, amountMinor: input.amountMinor,
currency: input.currency, currency: input.currency,
@@ -210,8 +237,15 @@ class FinanceRepositoryStub implements FinanceRepository {
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
recordedAt: Instant recordedAt: Instant
}) { }) {
this.addedPaymentRecords.push(input)
return { return {
id: 'payment-record-1', id: `payment-record-${this.addedPaymentRecords.length}`,
cycleId: input.cycleId,
cyclePeriod:
this.cycles.find((cycle) => cycle.id === input.cycleId)?.period ??
this.openCycleRecord?.period ??
null,
memberId: input.memberId, memberId: input.memberId,
kind: input.kind, kind: input.kind,
amountMinor: input.amountMinor, amountMinor: input.amountMinor,
@@ -220,6 +254,25 @@ class FinanceRepositoryStub implements FinanceRepository {
} }
} }
async getPaymentRecord(paymentId: string) {
return {
id: paymentId,
cycleId: this.openCycleRecord?.id ?? 'cycle-1',
cyclePeriod: this.openCycleRecord?.period ?? '2026-03',
memberId: 'alice',
kind: 'utilities' as const,
amountMinor: 0n,
currency: 'GEL' as const,
recordedAt: instantFromIso('2026-03-20T10:00:00.000Z')
}
}
async replacePaymentPurchaseAllocations(
input: Parameters<FinanceRepository['replacePaymentPurchaseAllocations']>[0]
) {
this.lastReplacedPaymentPurchaseAllocations = input
}
async updatePaymentRecord() { async updatePaymentRecord() {
return null return null
} }
@@ -236,18 +289,26 @@ class FinanceRepositoryStub implements FinanceRepository {
return this.utilityBills.reduce((sum, bill) => sum + bill.amountMinor, 0n) return this.utilityBills.reduce((sum, bill) => sum + bill.amountMinor, 0n)
} }
async listUtilityBillsForCycle() { async listUtilityBillsForCycle(cycleId: string) {
return this.utilityBills return this.utilityBills.filter((bill) => !bill.cycleId || bill.cycleId === cycleId)
} }
async listPaymentRecordsForCycle() { async listPaymentRecordsForCycle(cycleId: string) {
return this.paymentRecords return this.paymentRecords.filter((payment) => payment.cycleId === cycleId)
} }
async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> { async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> {
return this.purchases return this.purchases
} }
async listParsedPurchases(): Promise<readonly FinanceParsedPurchaseRecord[]> {
return this.purchases
}
async listPaymentPurchaseAllocations() {
return []
}
async getSettlementSnapshotLines() { async getSettlementSnapshotLines() {
return [] return []
} }
@@ -364,9 +425,10 @@ function createService(repository: FinanceRepositoryStub) {
describe('createFinanceCommandService', () => { describe('createFinanceCommandService', () => {
test('setRent falls back to the open cycle period when one is active', async () => { test('setRent falls back to the open cycle period when one is active', async () => {
const repository = new FinanceRepositoryStub() const repository = new FinanceRepositoryStub()
const currentPeriod = expectedCurrentCyclePeriod('Asia/Tbilisi', 20)
repository.openCycleRecord = { repository.openCycleRecord = {
id: 'cycle-1', id: 'cycle-1',
period: '2026-03', period: currentPeriod,
currency: 'GEL' currency: 'GEL'
} }
@@ -374,11 +436,11 @@ describe('createFinanceCommandService', () => {
const result = await service.setRent('700', undefined, undefined) const result = await service.setRent('700', undefined, undefined)
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(result?.period).toBe('2026-03') expect(result?.period).toBe(currentPeriod)
expect(result?.currency).toBe('USD') expect(result?.currency).toBe('USD')
expect(result?.amount.amountMinor).toBe(70000n) expect(result?.amount.amountMinor).toBe(70000n)
expect(repository.lastSavedRentRule).toEqual({ expect(repository.lastSavedRentRule).toEqual({
period: '2026-03', period: currentPeriod,
amountMinor: 70000n, amountMinor: 70000n,
currency: 'USD' currency: 'USD'
}) })
@@ -386,9 +448,10 @@ describe('createFinanceCommandService', () => {
test('getAdminCycleState prefers the open cycle and returns rent plus utility bills', async () => { test('getAdminCycleState prefers the open cycle and returns rent plus utility bills', async () => {
const repository = new FinanceRepositoryStub() const repository = new FinanceRepositoryStub()
const currentPeriod = expectedCurrentCyclePeriod('Asia/Tbilisi', 20)
repository.openCycleRecord = { repository.openCycleRecord = {
id: 'cycle-1', id: 'cycle-1',
period: '2026-03', period: currentPeriod,
currency: 'GEL' currency: 'GEL'
} }
repository.latestCycleRecord = { repository.latestCycleRecord = {
@@ -417,7 +480,7 @@ describe('createFinanceCommandService', () => {
expect(result).toEqual({ expect(result).toEqual({
cycle: { cycle: {
id: 'cycle-1', id: 'cycle-1',
period: '2026-03', period: currentPeriod,
currency: 'GEL' currency: 'GEL'
}, },
rentRule: { rentRule: {
@@ -498,6 +561,8 @@ describe('createFinanceCommandService', () => {
repository.purchases = [ repository.purchases = [
{ {
id: 'purchase-1', id: 'purchase-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
payerMemberId: 'alice', payerMemberId: 'alice',
amountMinor: 3000n, amountMinor: 3000n,
currency: 'GEL', currency: 'GEL',
@@ -508,6 +573,8 @@ describe('createFinanceCommandService', () => {
repository.paymentRecords = [ repository.paymentRecords = [
{ {
id: 'payment-1', id: 'payment-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
memberId: 'alice', memberId: 'alice',
kind: 'rent', kind: 'rent',
amountMinor: 50000n, amountMinor: 50000n,
@@ -517,8 +584,8 @@ describe('createFinanceCommandService', () => {
] ]
const service = createService(repository) const service = createService(repository)
const dashboard = await service.generateDashboard() const dashboard = await service.generateDashboard('2026-03')
const statement = await service.generateStatement() const statement = await service.generateStatement('2026-03')
expect(dashboard).not.toBeNull() expect(dashboard).not.toBeNull()
expect(dashboard?.currency).toBe('GEL') expect(dashboard?.currency).toBe('GEL')
@@ -578,7 +645,7 @@ describe('createFinanceCommandService', () => {
} }
const service = createService(repository) const service = createService(repository)
const dashboard = await service.generateDashboard() const dashboard = await service.generateDashboard('2026-03')
expect(dashboard?.period).toBe('2026-03') expect(dashboard?.period).toBe('2026-03')
}) })
@@ -638,6 +705,8 @@ describe('createFinanceCommandService', () => {
repository.purchases = [ repository.purchases = [
{ {
id: 'purchase-1', id: 'purchase-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
payerMemberId: 'alice', payerMemberId: 'alice',
amountMinor: 3000n, amountMinor: 3000n,
currency: 'GEL', currency: 'GEL',
@@ -742,6 +811,8 @@ describe('createFinanceCommandService', () => {
repository.purchases = [ repository.purchases = [
{ {
id: 'purchase-1', id: 'purchase-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
payerMemberId: 'alice', payerMemberId: 'alice',
amountMinor: 3000n, amountMinor: 3000n,
currency: 'GEL', currency: 'GEL',
@@ -823,6 +894,8 @@ describe('createFinanceCommandService', () => {
repository.purchases = [ repository.purchases = [
{ {
id: 'malformed-purchase-1', id: 'malformed-purchase-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
payerMemberId: 'alice', payerMemberId: 'alice',
amountMinor: 1000n, // Total is 10.00 GEL amountMinor: 1000n, // Total is 10.00 GEL
currency: 'GEL', currency: 'GEL',
@@ -882,7 +955,7 @@ describe('createFinanceCommandService', () => {
repository.rentRule = null repository.rentRule = null
const service = createService(repository) const service = createService(repository)
const dashboard = await service.generateDashboard() const dashboard = await service.generateDashboard('2026-03')
expect(dashboard).not.toBeNull() expect(dashboard).not.toBeNull()
expect(dashboard?.period).toBe('2026-03') expect(dashboard?.period).toBe('2026-03')
@@ -890,4 +963,310 @@ describe('createFinanceCommandService', () => {
expect(dashboard?.rentDisplayAmount.amountMinor).toBe(0n) expect(dashboard?.rentDisplayAmount.amountMinor).toBe(0n)
expect(dashboard?.totalDue.amountMinor).toBe(0n) expect(dashboard?.totalDue.amountMinor).toBe(0n)
}) })
test('generateDashboard carries unresolved purchases from prior cycles into the current cycle', async () => {
const repository = new FinanceRepositoryStub()
repository.members = [
{
id: 'alice',
telegramUserId: '1',
displayName: 'Alice',
rentShareWeight: 1,
isAdmin: true
},
{
id: 'bob',
telegramUserId: '2',
displayName: 'Bob',
rentShareWeight: 1,
isAdmin: false
}
]
repository.openCycleRecord = {
id: 'cycle-2026-04',
period: '2026-04',
currency: 'GEL'
}
repository.rentRule = {
amountMinor: 0n,
currency: 'GEL'
}
repository.utilityBills = [
{
id: 'utility-1',
billName: 'Electricity',
amountMinor: 5000n,
currency: 'GEL',
createdByMemberId: 'alice',
createdAt: instantFromIso('2026-04-05T12:00:00.000Z')
}
]
repository.purchases = [
{
id: 'purchase-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
payerMemberId: 'alice',
amountMinor: 3000n,
currency: 'GEL',
description: 'Soap',
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'),
splitMode: 'custom_amounts',
participants: [
{
memberId: 'alice',
included: true,
shareAmountMinor: 1500n
},
{
memberId: 'bob',
included: true,
shareAmountMinor: 1500n
}
]
}
]
const service = createService(repository)
const dashboard = await service.generateDashboard()
const bobLine = dashboard?.members.find((member) => member.memberId === 'bob')
const purchaseEntry = dashboard?.ledger.find((entry) => entry.id === 'purchase-1')
expect(bobLine?.purchaseOffset.amountMinor).toBe(1500n)
expect(bobLine?.utilityShare.amountMinor).toBe(2500n)
expect(purchaseEntry?.kind).toBe('purchase')
expect(purchaseEntry?.originPeriod).toBe('2026-03')
expect(purchaseEntry?.resolutionStatus).toBe('unresolved')
expect(purchaseEntry?.outstandingByMember).toEqual([
{
memberId: 'bob',
amount: Money.fromMinor(1500n, 'GEL')
}
])
})
test('addPayment allocates utilities overage to the oldest unresolved purchase balance', async () => {
const repository = new FinanceRepositoryStub()
repository.members = [
{
id: 'alice',
telegramUserId: '1',
displayName: 'Alice',
rentShareWeight: 1,
isAdmin: true
},
{
id: 'bob',
telegramUserId: '2',
displayName: 'Bob',
rentShareWeight: 1,
isAdmin: false
}
]
repository.openCycleRecord = {
id: 'cycle-2026-04',
period: '2026-04',
currency: 'GEL'
}
repository.rentRule = {
amountMinor: 0n,
currency: 'GEL'
}
repository.utilityBills = [
{
id: 'utility-1',
billName: 'Electricity',
amountMinor: 5000n,
currency: 'GEL',
createdByMemberId: 'alice',
createdAt: instantFromIso('2026-04-05T12:00:00.000Z')
}
]
repository.purchases = [
{
id: 'purchase-oldest',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
payerMemberId: 'alice',
amountMinor: 3000n,
currency: 'GEL',
description: 'Old soap',
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z'),
splitMode: 'custom_amounts',
participants: [
{
memberId: 'alice',
included: true,
shareAmountMinor: 1500n
},
{
memberId: 'bob',
included: true,
shareAmountMinor: 1500n
}
]
},
{
id: 'purchase-newer',
cycleId: 'cycle-2026-04',
cyclePeriod: '2026-04',
payerMemberId: 'alice',
amountMinor: 2000n,
currency: 'GEL',
description: 'New sponge',
occurredAt: instantFromIso('2026-04-07T11:00:00.000Z'),
splitMode: 'custom_amounts',
participants: [
{
memberId: 'alice',
included: true,
shareAmountMinor: 1000n
},
{
memberId: 'bob',
included: true,
shareAmountMinor: 1000n
}
]
}
]
const service = createService(repository)
await service.addPayment('bob', 'utilities', '40.00', 'GEL', '2026-04')
expect(repository.lastReplacedPaymentPurchaseAllocations).toEqual({
paymentRecordId: 'payment-record-1',
allocations: [
{
purchaseId: 'purchase-oldest',
memberId: 'bob',
amountMinor: 1500n
}
]
})
})
test('generateDashboard aggregates overdue payments by kind across unresolved past cycles', async () => {
const repository = new FinanceRepositoryStub()
repository.members = [
{
id: 'alice',
telegramUserId: '1',
displayName: 'Alice',
rentShareWeight: 1,
isAdmin: true
},
{
id: 'bob',
telegramUserId: '2',
displayName: 'Bob',
rentShareWeight: 1,
isAdmin: false
}
]
repository.cycles = [
{ id: 'cycle-2026-01', period: '2026-01', currency: 'GEL' },
{ id: 'cycle-2026-02', period: '2026-02', currency: 'GEL' },
{ id: 'cycle-2026-03', period: '2026-03', currency: 'GEL' }
]
repository.openCycleRecord = repository.cycles[2]!
repository.latestCycleRecord = repository.cycles[2]!
repository.rentRule = {
amountMinor: 2000n,
currency: 'GEL'
}
repository.utilityBills = [
{
id: 'utility-2026-02',
cycleId: 'cycle-2026-02',
billName: 'Electricity',
amountMinor: 600n,
currency: 'GEL',
createdByMemberId: 'alice',
createdAt: instantFromIso('2026-02-10T12:00:00.000Z')
}
]
repository.paymentRecords = [
{
id: 'payment-1',
cycleId: 'cycle-2026-03',
cyclePeriod: '2026-03',
memberId: 'bob',
kind: 'rent',
amountMinor: 1000n,
currency: 'GEL',
recordedAt: instantFromIso('2026-03-18T12:00:00.000Z')
}
]
const service = createService(repository)
const dashboard = await service.generateDashboard()
const bobLine = dashboard?.members.find((member) => member.memberId === 'bob')
expect(bobLine?.overduePayments).toEqual([
{
kind: 'rent',
amountMinor: 2000n,
periods: ['2026-01', '2026-02']
},
{
kind: 'utilities',
amountMinor: 300n,
periods: ['2026-02']
}
])
})
test('addPayment without explicit period applies overdue payments oldest-first across cycles', async () => {
const repository = new FinanceRepositoryStub()
repository.members = [
{
id: 'alice',
telegramUserId: '1',
displayName: 'Alice',
rentShareWeight: 1,
isAdmin: true
},
{
id: 'bob',
telegramUserId: '2',
displayName: 'Bob',
rentShareWeight: 1,
isAdmin: false
}
]
repository.cycles = [
{ id: 'cycle-2026-01', period: '2026-01', currency: 'GEL' },
{ id: 'cycle-2026-02', period: '2026-02', currency: 'GEL' },
{ id: 'cycle-2026-03', period: '2026-03', currency: 'GEL' }
]
repository.openCycleRecord = repository.cycles[2]!
repository.latestCycleRecord = repository.cycles[2]!
repository.rentRule = {
amountMinor: 2000n,
currency: 'GEL'
}
const service = createService(repository)
await service.addPayment('bob', 'rent', '15.00', 'GEL')
expect(repository.addedPaymentRecords).toEqual([
{
cycleId: 'cycle-2026-01',
memberId: 'bob',
kind: 'rent',
amountMinor: 1000n,
currency: 'GEL',
recordedAt: repository.addedPaymentRecords[0]!.recordedAt
},
{
cycleId: 'cycle-2026-02',
memberId: 'bob',
kind: 'rent',
amountMinor: 500n,
currency: 'GEL',
recordedAt: repository.addedPaymentRecords[1]!.recordedAt
}
])
})
}) })

View File

@@ -4,9 +4,12 @@ import type {
ExchangeRateProvider, ExchangeRateProvider,
FinanceCycleRecord, FinanceCycleRecord,
FinanceMemberRecord, FinanceMemberRecord,
FinanceMemberOverduePaymentRecord,
FinancePaymentKind, FinancePaymentKind,
FinancePaymentPurchaseAllocationRecord,
FinanceRentRuleRecord, FinanceRentRuleRecord,
FinanceRepository, FinanceRepository,
HouseholdBillingSettingsRecord,
HouseholdConfigurationRepository, HouseholdConfigurationRepository,
HouseholdMemberAbsencePolicy, HouseholdMemberAbsencePolicy,
HouseholdMemberAbsencePolicyRecord, HouseholdMemberAbsencePolicyRecord,
@@ -116,6 +119,7 @@ export interface FinanceDashboardMemberLine {
netDue: Money netDue: Money
paid: Money paid: Money
remaining: Money remaining: Money
overduePayments: readonly FinanceMemberOverduePaymentRecord[]
explanations: readonly string[] explanations: readonly string[]
} }
@@ -140,6 +144,13 @@ export interface FinanceDashboardLedgerEntry {
shareAmount: Money | null shareAmount: Money | null
}[] }[]
payerMemberId?: string payerMemberId?: string
originPeriod?: string | null
resolutionStatus?: 'unresolved' | 'resolved'
resolvedAt?: string | null
outstandingByMember?: readonly {
memberId: string
amount: Money
}[]
} }
export interface FinanceDashboard { export interface FinanceDashboard {
@@ -227,6 +238,95 @@ interface ConvertedCycleMoney {
fxEffectiveDate: string | null fxEffectiveDate: string | null
} }
interface PurchaseHistoryState {
purchase: Awaited<ReturnType<FinanceRepository['listParsedPurchases']>>[number]
converted: ConvertedCycleMoney
outstandingByMemberId: ReadonlyMap<string, Money>
outstandingTotal: Money
resolvedAt: string | null
}
interface CycleBaseMemberLine {
memberId: string
rentShare: Money
utilityShare: Money
rentPaid: Money
utilityPaid: Money
}
interface MutableOverdueSummary {
rent: { amountMinor: bigint; periods: string[] }
utilities: { amountMinor: bigint; periods: string[] }
}
function periodFromInstant(instant: Temporal.Instant | null | undefined): string | null {
if (!instant) {
return null
}
const zdt = instant.toZonedDateTimeISO('UTC')
return `${zdt.year}-${String(zdt.month).padStart(2, '0')}`
}
function purchaseOriginPeriod(
purchase: Awaited<ReturnType<FinanceRepository['listParsedPurchases']>>[number]
): string | null {
return purchase.cyclePeriod ?? periodFromInstant(purchase.occurredAt)
}
function buildPurchaseShareMap(input: {
purchase: Awaited<ReturnType<FinanceRepository['listParsedPurchases']>>[number]
amount: Money
activePurchaseParticipantIds: readonly string[]
}): ReadonlyMap<string, Money> {
const shares = new Map<string, Money>()
const explicitParticipants =
input.purchase.participants?.filter((participant) => participant.included !== false) ?? []
if (explicitParticipants.length > 0) {
const explicitShares = explicitParticipants.filter(
(participant) => participant.shareAmountMinor !== null
)
if (explicitShares.length > 0) {
for (const participant of explicitShares) {
shares.set(
participant.memberId,
Money.fromMinor(participant.shareAmountMinor!, input.amount.currency)
)
}
return shares
}
const splitShares = input.amount.splitEvenly(explicitParticipants.length)
for (const [index, participant] of explicitParticipants.entries()) {
shares.set(participant.memberId, splitShares[index] ?? Money.zero(input.amount.currency))
}
return shares
}
const fallbackIds = input.activePurchaseParticipantIds
const splitShares = input.amount.splitEvenly(fallbackIds.length)
for (const [index, memberId] of fallbackIds.entries()) {
shares.set(memberId, splitShares[index] ?? Money.zero(input.amount.currency))
}
return shares
}
function sumAllocationMinor(
allocations: readonly FinancePaymentPurchaseAllocationRecord[],
purchaseId: string,
memberId: string
): bigint {
return allocations
.filter(
(allocation) => allocation.purchaseId === purchaseId && allocation.memberId === memberId
)
.reduce((sum, allocation) => sum + allocation.amountMinor, 0n)
}
async function convertIntoCycleCurrency( async function convertIntoCycleCurrency(
dependencies: FinanceCommandServiceDependencies, dependencies: FinanceCommandServiceDependencies,
input: { input: {
@@ -289,6 +389,260 @@ async function convertIntoCycleCurrency(
} }
} }
async function buildCycleBaseMemberLines(input: {
dependencies: FinanceCommandServiceDependencies
cycle: FinanceCycleRecord
members: readonly HouseholdMemberRecord[]
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
settings: HouseholdBillingSettingsRecord
}): Promise<readonly CycleBaseMemberLine[]> {
const period = BillingPeriod.fromString(input.cycle.period)
const resolvedAbsencePolicies = resolveMemberAbsencePolicies({
members: input.members,
policies: input.memberAbsencePolicies,
period: input.cycle.period
})
const [rentRule, utilityBills, paymentRecords] = await Promise.all([
input.dependencies.repository.getRentRuleForPeriod(input.cycle.period),
input.dependencies.repository.listUtilityBillsForCycle(input.cycle.id),
input.dependencies.repository.listPaymentRecordsForCycle(input.cycle.id)
])
const rentAmountMinor = rentRule?.amountMinor ?? 0n
const rentCurrency = rentRule?.currency ?? input.cycle.currency
const convertedRent = await convertIntoCycleCurrency(input.dependencies, {
cycle: input.cycle,
period,
lockDay: input.settings.rentWarningDay,
timezone: input.settings.timezone,
amount: Money.fromMinor(rentAmountMinor, rentCurrency)
})
const convertedUtilityBills = await Promise.all(
utilityBills.map(async (bill) => {
const converted = await convertIntoCycleCurrency(input.dependencies, {
cycle: input.cycle,
period,
lockDay: input.settings.utilitiesReminderDay,
timezone: input.settings.timezone,
amount: Money.fromMinor(bill.amountMinor, bill.currency)
})
return converted.settlementAmount
})
)
const utilities = convertedUtilityBills.reduce(
(sum, amount) => sum.add(amount),
Money.zero(input.cycle.currency)
)
const settlement = calculateMonthlySettlement({
cycleId: BillingCycleId.from(input.cycle.id),
period,
rent: convertedRent.settlementAmount,
utilities,
utilitySplitMode: 'equal',
members: input.members.map((member) => ({
memberId: MemberId.from(member.id),
active: member.status !== 'left',
participatesInRent:
member.status === 'left'
? false
: (resolvedAbsencePolicies.get(member.id)?.policy ?? 'resident') !== 'inactive',
participatesInUtilities:
member.status === 'away'
? (resolvedAbsencePolicies.get(member.id)?.policy ?? 'resident') ===
'away_rent_and_utilities'
: member.status !== 'left',
participatesInPurchases: member.status === 'active',
rentWeight: member.rentShareWeight
})),
purchases: []
})
const rentPaidByMemberId = new Map<string, Money>()
const utilityPaidByMemberId = new Map<string, Money>()
for (const payment of paymentRecords) {
const targetMap = payment.kind === 'rent' ? rentPaidByMemberId : utilityPaidByMemberId
const current = targetMap.get(payment.memberId) ?? Money.zero(input.cycle.currency)
targetMap.set(
payment.memberId,
current.add(Money.fromMinor(payment.amountMinor, payment.currency))
)
}
return settlement.lines.map((line) => ({
memberId: line.memberId.toString(),
rentShare: line.rentShare,
utilityShare: line.utilityShare,
rentPaid: rentPaidByMemberId.get(line.memberId.toString()) ?? Money.zero(input.cycle.currency),
utilityPaid:
utilityPaidByMemberId.get(line.memberId.toString()) ?? Money.zero(input.cycle.currency)
}))
}
async function computeMemberOverduePayments(input: {
dependencies: FinanceCommandServiceDependencies
currentCycle: FinanceCycleRecord
members: readonly HouseholdMemberRecord[]
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
settings: HouseholdBillingSettingsRecord
}): Promise<ReadonlyMap<string, readonly FinanceMemberOverduePaymentRecord[]>> {
const localDate = localDateInTimezone(input.settings.timezone)
const overdueByMemberId = new Map<string, MutableOverdueSummary>()
const cycles = (await input.dependencies.repository.listCycles()).filter(
(cycle) => cycle.period.localeCompare(input.currentCycle.period) <= 0
)
for (const cycle of cycles) {
const baseLines = await buildCycleBaseMemberLines({
dependencies: input.dependencies,
cycle,
members: input.members,
memberAbsencePolicies: input.memberAbsencePolicies,
settings: input.settings
})
const rentDueDate = billingPeriodLockDate(
BillingPeriod.fromString(cycle.period),
input.settings.rentDueDay
)
const utilitiesDueDate = billingPeriodLockDate(
BillingPeriod.fromString(cycle.period),
input.settings.utilitiesDueDay
)
for (const line of baseLines) {
const current = overdueByMemberId.get(line.memberId) ?? {
rent: { amountMinor: 0n, periods: [] },
utilities: { amountMinor: 0n, periods: [] }
}
const rentRemainingMinor = line.rentShare.subtract(line.rentPaid).amountMinor
if (Temporal.PlainDate.compare(localDate, rentDueDate) > 0 && rentRemainingMinor > 0n) {
current.rent.amountMinor += rentRemainingMinor
current.rent.periods.push(cycle.period)
}
const utilityRemainingMinor = line.utilityShare.subtract(line.utilityPaid).amountMinor
if (
Temporal.PlainDate.compare(localDate, utilitiesDueDate) > 0 &&
utilityRemainingMinor > 0n
) {
current.utilities.amountMinor += utilityRemainingMinor
current.utilities.periods.push(cycle.period)
}
overdueByMemberId.set(line.memberId, current)
}
}
return new Map(
[...overdueByMemberId.entries()].map(([memberId, overdue]) => {
const items: FinanceMemberOverduePaymentRecord[] = []
if (overdue.rent.amountMinor > 0n) {
items.push({
kind: 'rent',
amountMinor: overdue.rent.amountMinor,
periods: overdue.rent.periods
})
}
if (overdue.utilities.amountMinor > 0n) {
items.push({
kind: 'utilities',
amountMinor: overdue.utilities.amountMinor,
periods: overdue.utilities.periods
})
}
return [memberId, items] as const
})
)
}
async function resolveAutomaticPaymentTargets(input: {
dependencies: FinanceCommandServiceDependencies
currentCycle: FinanceCycleRecord
members: readonly HouseholdMemberRecord[]
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
settings: HouseholdBillingSettingsRecord
memberId: string
kind: FinancePaymentKind
}): Promise<
readonly {
cycle: FinanceCycleRecord
baseRemainingMinor: bigint
allowOverflow: boolean
}[]
> {
const localDate = localDateInTimezone(input.settings.timezone)
const cycles = (await input.dependencies.repository.listCycles()).filter(
(cycle) => cycle.period.localeCompare(input.currentCycle.period) <= 0
)
const overdueTargets: {
cycle: FinanceCycleRecord
baseRemainingMinor: bigint
allowOverflow: boolean
}[] = []
for (const cycle of cycles) {
const baseLine = (
await buildCycleBaseMemberLines({
dependencies: input.dependencies,
cycle,
members: input.members,
memberAbsencePolicies: input.memberAbsencePolicies,
settings: input.settings
})
).find((line) => line.memberId === input.memberId)
if (!baseLine) {
continue
}
const dueDate = billingPeriodLockDate(
BillingPeriod.fromString(cycle.period),
input.kind === 'rent' ? input.settings.rentDueDay : input.settings.utilitiesDueDay
)
if (Temporal.PlainDate.compare(localDate, dueDate) <= 0) {
continue
}
const remainingMinor =
input.kind === 'rent'
? baseLine.rentShare.subtract(baseLine.rentPaid).amountMinor
: baseLine.utilityShare.subtract(baseLine.utilityPaid).amountMinor
if (remainingMinor <= 0n) {
continue
}
overdueTargets.push({
cycle,
baseRemainingMinor: remainingMinor,
allowOverflow: false
})
}
const currentCycleAlreadyIncluded = overdueTargets.some(
(target) => target.cycle.id === input.currentCycle.id
)
if (currentCycleAlreadyIncluded) {
return overdueTargets.map((target, index) => ({
...target,
allowOverflow: index === overdueTargets.length - 1
}))
}
return [
...overdueTargets,
{
cycle: input.currentCycle,
baseRemainingMinor: 0n,
allowOverflow: true
}
]
}
async function buildFinanceDashboard( async function buildFinanceDashboard(
dependencies: FinanceCommandServiceDependencies, dependencies: FinanceCommandServiceDependencies,
periodArg?: string periodArg?: string
@@ -323,15 +677,23 @@ async function buildFinanceDashboard(
policies: memberAbsencePolicies, policies: memberAbsencePolicies,
period: cycle.period period: cycle.period
}) })
const [purchases, utilityBills] = await Promise.all([ const [allPurchases, utilityBills, paymentPurchaseAllocations] = await Promise.all([
dependencies.repository.listParsedPurchasesForRange(start, end), dependencies.repository.listParsedPurchases(),
dependencies.repository.listUtilityBillsForCycle(cycle.id) dependencies.repository.listUtilityBillsForCycle(cycle.id),
dependencies.repository.listPaymentPurchaseAllocations()
]) ])
const paymentRecords = await dependencies.repository.listPaymentRecordsForCycle(cycle.id) const paymentRecords = await dependencies.repository.listPaymentRecordsForCycle(cycle.id)
const previousCycle = await dependencies.repository.getCycleByPeriod(period.previous().toString()) const previousCycle = await dependencies.repository.getCycleByPeriod(period.previous().toString())
const previousSnapshotLines = previousCycle const previousSnapshotLines = previousCycle
? await dependencies.repository.getSettlementSnapshotLines(previousCycle.id) ? await dependencies.repository.getSettlementSnapshotLines(previousCycle.id)
: [] : []
const overduePaymentsByMemberId = await computeMemberOverduePayments({
dependencies,
currentCycle: cycle,
members,
memberAbsencePolicies,
settings
})
const previousUtilityShareByMemberId = new Map( const previousUtilityShareByMemberId = new Map(
previousSnapshotLines.map((line) => [ previousSnapshotLines.map((line) => [
line.memberId, line.memberId,
@@ -365,7 +727,7 @@ async function buildFinanceDashboard(
) )
const convertedPurchases = await Promise.all( const convertedPurchases = await Promise.all(
purchases.map(async (purchase) => { allPurchases.map(async (purchase) => {
const converted = await convertIntoCycleCurrency(dependencies, { const converted = await convertIntoCycleCurrency(dependencies, {
cycle, cycle,
period, period,
@@ -381,6 +743,82 @@ async function buildFinanceDashboard(
}) })
) )
const currentCyclePurchaseIds = new Set(
allPurchases
.filter((purchase) => {
if (purchase.cycleId === cycle.id || purchase.cyclePeriod === cycle.period) {
return true
}
if (purchase.cycleId) {
return false
}
if (!purchase.occurredAt) {
return false
}
return (
Temporal.Instant.compare(purchase.occurredAt, start) >= 0 &&
Temporal.Instant.compare(purchase.occurredAt, end) < 0
)
})
.map((purchase) => purchase.id)
)
const activePurchaseParticipantIds = members
.filter((member) => member.status === 'active')
.map((member) => member.id)
const purchaseHistory: PurchaseHistoryState[] = convertedPurchases.map(
({ purchase, converted }) => {
const shareMap = buildPurchaseShareMap({
purchase,
amount: converted.settlementAmount,
activePurchaseParticipantIds
})
const outstandingEntries = [...shareMap.entries()]
.filter(([memberId]) => memberId !== purchase.payerMemberId)
.map(([memberId, shareAmount]) => {
const allocatedMinor = sumAllocationMinor(
paymentPurchaseAllocations,
purchase.id,
memberId
)
const outstandingMinor =
shareAmount.amountMinor > allocatedMinor ? shareAmount.amountMinor - allocatedMinor : 0n
return [
memberId,
Money.fromMinor(outstandingMinor, converted.settlementAmount.currency)
] as const
})
.filter(([, amount]) => amount.amountMinor > 0n)
const outstandingByMemberId = new Map<string, Money>(outstandingEntries)
const outstandingTotal = outstandingEntries.reduce(
(sum, [, amount]) => sum.add(amount),
Money.zero(converted.settlementAmount.currency)
)
const resolvedAt =
outstandingEntries.length === 0
? (paymentPurchaseAllocations
.filter((allocation) => allocation.purchaseId === purchase.id)
.map((allocation) => allocation.recordedAt.toString())
.sort()
.at(-1) ?? null)
: null
return {
purchase,
converted,
outstandingByMemberId,
outstandingTotal,
resolvedAt
}
}
)
const utilities = convertedUtilityBills.reduce( const utilities = convertedUtilityBills.reduce(
(sum, current) => sum.add(current.converted.settlementAmount), (sum, current) => sum.add(current.converted.settlementAmount),
Money.zero(cycle.currency) Money.zero(cycle.currency)
@@ -407,41 +845,49 @@ async function buildFinanceDashboard(
participatesInPurchases: member.status === 'active', participatesInPurchases: member.status === 'active',
rentWeight: member.rentShareWeight rentWeight: member.rentShareWeight
})), })),
purchases: convertedPurchases.map(({ purchase, converted }) => { purchases: purchaseHistory
const nextPurchase: { .filter(
purchaseId: PurchaseEntryId ({ purchase, outstandingTotal }) =>
payerId: MemberId currentCyclePurchaseIds.has(purchase.id) || outstandingTotal.amountMinor > 0n
amount: Money )
splitMode: 'equal' | 'custom_amounts' .map(({ purchase, converted, outstandingByMemberId, outstandingTotal }) => {
participants?: { const nextPurchase: {
memberId: MemberId purchaseId: PurchaseEntryId
shareAmount?: Money payerId: MemberId
}[] amount: Money
} = { splitMode: 'equal' | 'custom_amounts'
purchaseId: PurchaseEntryId.from(purchase.id), participants?: {
payerId: MemberId.from(purchase.payerMemberId), memberId: MemberId
amount: converted.settlementAmount, shareAmount?: Money
splitMode: purchase.splitMode ?? 'equal' }[]
} } = {
purchaseId: PurchaseEntryId.from(purchase.id),
payerId: MemberId.from(purchase.payerMemberId),
amount: currentCyclePurchaseIds.has(purchase.id)
? converted.settlementAmount
: outstandingTotal,
splitMode: 'custom_amounts'
}
if (purchase.participants) { const participantShareMap = currentCyclePurchaseIds.has(purchase.id)
nextPurchase.participants = purchase.participants ? buildPurchaseShareMap({
.filter((participant) => participant.included !== false) purchase,
.map((participant) => ({ amount: converted.settlementAmount,
memberId: MemberId.from(participant.memberId), activePurchaseParticipantIds
...(participant.shareAmountMinor !== null })
? { : outstandingByMemberId
shareAmount: Money.fromMinor(
participant.shareAmountMinor, nextPurchase.participants = [...participantShareMap.entries()]
converted.settlementAmount.currency .filter(([memberId]) =>
) currentCyclePurchaseIds.has(purchase.id) ? true : memberId !== purchase.payerMemberId
} )
: {}) .map(([memberId, shareAmount]) => ({
memberId: MemberId.from(memberId),
shareAmount
})) }))
}
return nextPurchase return nextPurchase
}) })
}) })
await dependencies.repository.replaceSettlementSnapshot({ await dependencies.repository.replaceSettlementSnapshot({
@@ -502,6 +948,12 @@ async function buildFinanceDashboard(
remaining: line.netDue.subtract( remaining: line.netDue.subtract(
paymentsByMemberId.get(line.memberId.toString()) ?? Money.zero(cycle.currency) paymentsByMemberId.get(line.memberId.toString()) ?? Money.zero(cycle.currency)
), ),
overduePayments:
overduePaymentsByMemberId.get(line.memberId.toString())?.map((overdue) => ({
kind: overdue.kind,
amountMinor: overdue.amountMinor,
periods: overdue.periods
})) ?? [],
explanations: line.explanations explanations: line.explanations
})) }))
@@ -523,7 +975,7 @@ async function buildFinanceDashboard(
occurredAt: bill.createdAt.toString(), occurredAt: bill.createdAt.toString(),
paymentKind: null paymentKind: null
})), })),
...convertedPurchases.map(({ purchase, converted }) => { ...purchaseHistory.map(({ purchase, converted, outstandingByMemberId, resolvedAt }) => {
const entry: FinanceDashboardLedgerEntry = { const entry: FinanceDashboardLedgerEntry = {
id: purchase.id, id: purchase.id,
kind: 'purchase', kind: 'purchase',
@@ -539,7 +991,14 @@ async function buildFinanceDashboard(
actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null, actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null,
occurredAt: purchase.occurredAt?.toString() ?? null, occurredAt: purchase.occurredAt?.toString() ?? null,
paymentKind: null, paymentKind: null,
purchaseSplitMode: purchase.splitMode ?? 'equal' purchaseSplitMode: purchase.splitMode ?? 'equal',
originPeriod: purchaseOriginPeriod(purchase),
resolutionStatus: outstandingByMemberId.size === 0 ? 'resolved' : 'unresolved',
resolvedAt,
outstandingByMember: [...outstandingByMemberId.entries()].map(([memberId, amount]) => ({
memberId,
amount
}))
} }
if (purchase.participants) { if (purchase.participants) {
@@ -606,6 +1065,97 @@ async function buildFinanceDashboard(
} }
} }
async function allocatePaymentPurchaseOverage(input: {
dependencies: FinanceCommandServiceDependencies
cyclePeriod: string
memberId: string
kind: FinancePaymentKind
paymentAmount: Money
settings: HouseholdBillingSettingsRecord
}): Promise<
readonly {
purchaseId: string
memberId: string
amountMinor: bigint
}[]
> {
const policy = input.settings.paymentBalanceAdjustmentPolicy ?? 'utilities'
if (policy === 'separate' || policy !== input.kind) {
return []
}
const dashboard = await buildFinanceDashboard(input.dependencies, input.cyclePeriod)
if (!dashboard) {
return []
}
const memberLine = dashboard.members.find((member) => member.memberId === input.memberId)
if (!memberLine) {
return []
}
const baseAmount = input.kind === 'rent' ? memberLine.rentShare : memberLine.utilityShare
let remainingMinor = input.paymentAmount.amountMinor - baseAmount.amountMinor
if (remainingMinor <= 0n) {
return []
}
const purchaseEntries = dashboard.ledger
.filter(
(
entry
): entry is FinanceDashboardLedgerEntry & {
kind: 'purchase'
outstandingByMember: readonly { memberId: string; amount: Money }[]
} =>
entry.kind === 'purchase' &&
entry.resolutionStatus === 'unresolved' &&
Array.isArray(entry.outstandingByMember)
)
.sort((left, right) => {
const leftKey = `${left.originPeriod ?? ''}:${left.occurredAt ?? ''}:${left.id}`
const rightKey = `${right.originPeriod ?? ''}:${right.occurredAt ?? ''}:${right.id}`
return leftKey.localeCompare(rightKey)
})
const allocations: {
purchaseId: string
memberId: string
amountMinor: bigint
}[] = []
for (const entry of purchaseEntries) {
const memberOutstanding = entry.outstandingByMember.find(
(outstanding) => outstanding.memberId === input.memberId
)
if (!memberOutstanding || memberOutstanding.amount.amountMinor <= 0n) {
continue
}
const allocatedMinor =
remainingMinor >= memberOutstanding.amount.amountMinor
? memberOutstanding.amount.amountMinor
: remainingMinor
if (allocatedMinor <= 0n) {
continue
}
allocations.push({
purchaseId: entry.id,
memberId: input.memberId,
amountMinor: allocatedMinor
})
remainingMinor -= allocatedMinor
if (remainingMinor === 0n) {
break
}
}
return allocations
}
export interface FinanceCommandService { export interface FinanceCommandService {
getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null> getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null>
getOpenCycle(): Promise<FinanceCycleRecord | null> getOpenCycle(): Promise<FinanceCycleRecord | null>
@@ -685,7 +1235,8 @@ export interface FinanceCommandService {
memberId: string, memberId: string,
kind: FinancePaymentKind, kind: FinancePaymentKind,
amountArg: string, amountArg: string,
currencyArg?: string currencyArg?: string,
periodArg?: string
): Promise<{ ): Promise<{
paymentId: string paymentId: string
amount: Money amount: Money
@@ -1019,28 +1570,100 @@ export function createFinanceCommandService(
return repository.deleteParsedPurchase(purchaseId) return repository.deleteParsedPurchase(purchaseId)
}, },
async addPayment(memberId, kind, amountArg, currencyArg) { async addPayment(memberId, kind, amountArg, currencyArg, periodArg) {
const [openCycle, settings] = await Promise.all([ const [settings, members, memberAbsencePolicies] = await Promise.all([
ensureExpectedCycle(), householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId),
householdConfigurationRepository.getHouseholdBillingSettings(dependencies.householdId) householdConfigurationRepository.listHouseholdMembers(dependencies.householdId),
householdConfigurationRepository.listHouseholdMemberAbsencePolicies(
dependencies.householdId
)
]) ])
const currentCycle = periodArg
? await repository.getCycleByPeriod(BillingPeriod.fromString(periodArg).toString())
: await ensureExpectedCycle()
if (!currentCycle) {
return null
}
const currency = parseCurrency(currencyArg, settings.settlementCurrency) const currency = parseCurrency(currencyArg, settings.settlementCurrency)
const amount = Money.fromMajor(amountArg, currency) const amount = Money.fromMajor(amountArg, currency)
const payment = await repository.addPaymentRecord({ const paymentTargets = periodArg
cycleId: openCycle.id, ? [
memberId, {
kind, cycle: currentCycle,
amountMinor: amount.amountMinor, baseRemainingMinor: 0n,
currency, allowOverflow: true
recordedAt: nowInstant() }
}) ]
: await resolveAutomaticPaymentTargets({
dependencies,
currentCycle,
members,
memberAbsencePolicies,
settings,
memberId,
kind
})
let remainingMinor = amount.amountMinor
let firstPayment: Awaited<ReturnType<FinanceRepository['addPaymentRecord']>> | null = null
for (const target of paymentTargets) {
if (remainingMinor <= 0n) {
break
}
const amountMinor =
target.allowOverflow || target.baseRemainingMinor <= 0n
? remainingMinor
: remainingMinor > target.baseRemainingMinor
? target.baseRemainingMinor
: remainingMinor
if (amountMinor <= 0n) {
continue
}
const payment = await repository.addPaymentRecord({
cycleId: target.cycle.id,
memberId,
kind,
amountMinor,
currency,
recordedAt: nowInstant()
})
if (!firstPayment) {
firstPayment = payment
}
const allocations = target.allowOverflow
? await allocatePaymentPurchaseOverage({
dependencies,
cyclePeriod: target.cycle.period,
memberId,
kind,
paymentAmount: Money.fromMinor(amountMinor, currency),
settings
})
: []
await repository.replacePaymentPurchaseAllocations({
paymentRecordId: payment.id,
allocations
})
remainingMinor -= amountMinor
}
if (!firstPayment) {
return null
}
return { return {
paymentId: payment.id, paymentId: firstPayment.id,
amount, amount,
currency, currency,
period: openCycle.period period: firstPayment.cyclePeriod ?? currentCycle.period
} }
}, },
@@ -1050,6 +1673,10 @@ export function createFinanceCommandService(
) )
const currency = parseCurrency(currencyArg, settings.settlementCurrency) const currency = parseCurrency(currencyArg, settings.settlementCurrency)
const amount = Money.fromMajor(amountArg, currency) const amount = Money.fromMajor(amountArg, currency)
const existingPayment = await repository.getPaymentRecord(paymentId)
if (!existingPayment) {
return null
}
const payment = await repository.updatePaymentRecord({ const payment = await repository.updatePaymentRecord({
paymentId, paymentId,
memberId, memberId,
@@ -1062,6 +1689,25 @@ export function createFinanceCommandService(
return null return null
} }
await repository.replacePaymentPurchaseAllocations({
paymentRecordId: paymentId,
allocations: []
})
const allocations = await allocatePaymentPurchaseOverage({
dependencies,
cyclePeriod:
existingPayment.cyclePeriod ?? expectedOpenCyclePeriod(settings, nowInstant()).toString(),
memberId,
kind,
paymentAmount: amount,
settings
})
await repository.replacePaymentPurchaseAllocations({
paymentRecordId: paymentId,
allocations
})
return { return {
paymentId: payment.id, paymentId: payment.id,
amount, amount,

View File

@@ -183,6 +183,7 @@ function createRepositoryStub() {
isActive: input.isActive isActive: input.isActive
}), }),
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null, updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [], listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -207,6 +207,9 @@ function createRepositoryStub() {
async promoteHouseholdAdmin() { async promoteHouseholdAdmin() {
return null return null
}, },
async demoteHouseholdAdmin() {
return null
},
async updateHouseholdMemberRentShareWeight() { async updateHouseholdMemberRentShareWeight() {
return null return null
}, },

View File

@@ -316,6 +316,21 @@ function createRepositoryStub() {
members.set(`${householdId}:${member.telegramUserId}`, next) members.set(`${householdId}:${member.telegramUserId}`, next)
return next return next
}, },
async demoteHouseholdAdmin(householdId, memberId) {
const member = [...members.values()].find(
(entry) => entry.householdId === householdId && entry.id === memberId
)
if (!member) {
return null
}
const next = {
...member,
isAdmin: false
}
members.set(`${householdId}:${member.telegramUserId}`, next)
return next
},
async updateHouseholdMemberRentShareWeight(householdId, memberId, rentShareWeight) { async updateHouseholdMemberRentShareWeight(householdId, memberId, rentShareWeight) {
const member = [...members.values()].find( const member = [...members.values()].find(

View File

@@ -117,6 +117,7 @@ function createRepository(): HouseholdConfigurationRepository {
isActive: input.isActive isActive: input.isActive
}), }),
promoteHouseholdAdmin: async () => null, promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null, updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [], listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -215,6 +215,20 @@ function repository(): HouseholdConfigurationRepository {
isAdmin: true isAdmin: true
} }
: null, : null,
demoteHouseholdAdmin: async (householdId, memberId) =>
memberId === 'member-123456'
? {
id: memberId,
householdId,
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
: null,
updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) => updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) =>
memberId === 'member-123456' memberId === 'member-123456'
? { ? {
@@ -512,6 +526,87 @@ describe('createMiniAppAdminService', () => {
}) })
}) })
test('demotes a household admin when another admin still exists', async () => {
const service = createMiniAppAdminService({
...repository(),
listHouseholdMembers: async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
},
{
id: 'member-999999',
householdId: 'household-1',
telegramUserId: '999999',
displayName: 'Mia',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
}
]
})
const result = await service.demoteMemberFromAdmin({
householdId: 'household-1',
actorIsAdmin: true,
memberId: 'member-123456'
})
expect(result).toEqual({
status: 'ok',
member: {
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
})
})
test('rejects demoting the last household admin', async () => {
const service = createMiniAppAdminService({
...repository(),
listHouseholdMembers: async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
}
]
})
const result = await service.demoteMemberFromAdmin({
householdId: 'household-1',
actorIsAdmin: true,
memberId: 'member-123456'
})
expect(result).toEqual({
status: 'rejected',
reason: 'last_admin'
})
})
test('updates the acting member display name', async () => { test('updates the acting member display name', async () => {
const service = createMiniAppAdminService(repository()) const service = createMiniAppAdminService(repository())

View File

@@ -173,6 +173,20 @@ export interface MiniAppAdminService {
reason: 'not_admin' | 'member_not_found' reason: 'not_admin' | 'member_not_found'
} }
> >
demoteMemberFromAdmin(input: {
householdId: string
actorIsAdmin: boolean
memberId: string
}): Promise<
| {
status: 'ok'
member: HouseholdMemberRecord
}
| {
status: 'rejected'
reason: 'not_admin' | 'member_not_found' | 'last_admin'
}
>
updateMemberRentShareWeight(input: { updateMemberRentShareWeight(input: {
householdId: string householdId: string
actorIsAdmin: boolean actorIsAdmin: boolean
@@ -649,6 +663,47 @@ export function createMiniAppAdminService(
} }
}, },
async demoteMemberFromAdmin(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
const members = await repository.listHouseholdMembers(input.householdId)
const targetMember = members.find((member) => member.id === input.memberId)
if (!targetMember) {
return {
status: 'rejected',
reason: 'member_not_found'
}
}
const adminCount = members.filter((member) => member.isAdmin).length
if (targetMember.isAdmin && adminCount <= 1) {
return {
status: 'rejected',
reason: 'last_admin'
}
}
const member = targetMember.isAdmin
? await repository.demoteHouseholdAdmin(input.householdId, input.memberId)
: targetMember
if (!member) {
return {
status: 'rejected',
reason: 'member_not_found'
}
}
return {
status: 'ok',
member
}
},
async updateMemberRentShareWeight(input) { async updateMemberRentShareWeight(input) {
if (!input.actorIsAdmin) { if (!input.actorIsAdmin) {
return { return {

View File

@@ -86,6 +86,8 @@ function createRepositoryStub(): Pick<
status: 'recorded', status: 'recorded',
paymentRecord: { paymentRecord: {
id: 'payment-1', id: 'payment-1',
cycleId: input.cycleId,
cyclePeriod: null,
memberId: input.memberId, memberId: input.memberId,
kind: input.kind, kind: input.kind,
amountMinor: input.amountMinor, amountMinor: input.amountMinor,
@@ -137,6 +139,7 @@ describe('createPaymentConfirmationService', () => {
netDue: Money.fromMajor('500.50', 'GEL'), netDue: Money.fromMajor('500.50', 'GEL'),
paid: Money.zero('GEL'), paid: Money.zero('GEL'),
remaining: Money.fromMajor('500.50', 'GEL'), remaining: Money.fromMajor('500.50', 'GEL'),
overduePayments: [],
explanations: [] explanations: []
} }
], ],
@@ -206,6 +209,7 @@ describe('createPaymentConfirmationService', () => {
netDue: Money.fromMajor('500.50', 'GEL'), netDue: Money.fromMajor('500.50', 'GEL'),
paid: Money.zero('GEL'), paid: Money.zero('GEL'),
remaining: Money.fromMajor('500.50', 'GEL'), remaining: Money.fromMajor('500.50', 'GEL'),
overduePayments: [],
explanations: [] explanations: []
} }
], ],

View File

@@ -23,6 +23,7 @@
"0019_faithful_madame_masque.sql": "38711341799b04a7c47fcc64fd19faf5b26e6f183d6a4c01d492b9929cd63641", "0019_faithful_madame_masque.sql": "38711341799b04a7c47fcc64fd19faf5b26e6f183d6a4c01d492b9929cd63641",
"0020_natural_mauler.sql": "a80a4a0196a3b4931040850089346d1bc99b34a5afca77d6d62478ee4b8902c1", "0020_natural_mauler.sql": "a80a4a0196a3b4931040850089346d1bc99b34a5afca77d6d62478ee4b8902c1",
"0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad", "0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad",
"0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1" "0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1",
"0022_carry_purchase_history.sql": "f031c9736e43e71eec3263a323332c29de9324c6409db034b0760051c8a9f074"
} }
} }

View File

@@ -0,0 +1,34 @@
ALTER TABLE "purchase_messages"
ADD COLUMN "cycle_id" uuid REFERENCES "billing_cycles"("id") ON DELETE SET NULL;
--> statement-breakpoint
CREATE INDEX "purchase_messages_cycle_idx" ON "purchase_messages" USING btree ("cycle_id");
--> statement-breakpoint
CREATE TABLE "payment_purchase_allocations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"payment_record_id" uuid NOT NULL,
"purchase_id" uuid NOT NULL,
"member_id" uuid NOT NULL,
"amount_minor" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "payment_purchase_allocations"
ADD CONSTRAINT "payment_purchase_allocations_payment_record_id_payment_records_id_fk"
FOREIGN KEY ("payment_record_id") REFERENCES "public"."payment_records"("id")
ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "payment_purchase_allocations"
ADD CONSTRAINT "payment_purchase_allocations_purchase_id_purchase_messages_id_fk"
FOREIGN KEY ("purchase_id") REFERENCES "public"."purchase_messages"("id")
ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "payment_purchase_allocations"
ADD CONSTRAINT "payment_purchase_allocations_member_id_members_id_fk"
FOREIGN KEY ("member_id") REFERENCES "public"."members"("id")
ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "payment_purchase_allocations_payment_idx"
ON "payment_purchase_allocations" USING btree ("payment_record_id");
--> statement-breakpoint
CREATE INDEX "payment_purchase_allocations_purchase_member_idx"
ON "payment_purchase_allocations" USING btree ("purchase_id","member_id");

View File

@@ -155,6 +155,13 @@
"when": 1774200000000, "when": 1774200000000,
"tag": "0021_sharp_payer", "tag": "0021_sharp_payer",
"breakpoints": true "breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1774205000000,
"tag": "0022_carry_purchase_history",
"breakpoints": true
} }
] ]
} }

View File

@@ -414,6 +414,9 @@ export const purchaseMessages = pgTable(
householdId: uuid('household_id') householdId: uuid('household_id')
.notNull() .notNull()
.references(() => households.id, { onDelete: 'cascade' }), .references(() => households.id, { onDelete: 'cascade' }),
cycleId: uuid('cycle_id').references(() => billingCycles.id, {
onDelete: 'set null'
}),
senderMemberId: uuid('sender_member_id').references(() => members.id, { senderMemberId: uuid('sender_member_id').references(() => members.id, {
onDelete: 'set null' onDelete: 'set null'
}), }),
@@ -444,6 +447,7 @@ export const purchaseMessages = pgTable(
table.householdId, table.householdId,
table.telegramThreadId table.telegramThreadId
), ),
cycleIdx: index('purchase_messages_cycle_idx').on(table.cycleId),
senderIdx: index('purchase_messages_sender_idx').on(table.senderTelegramUserId), senderIdx: index('purchase_messages_sender_idx').on(table.senderTelegramUserId),
tgMessageUnique: uniqueIndex('purchase_messages_household_tg_message_unique').on( tgMessageUnique: uniqueIndex('purchase_messages_household_tg_message_unique').on(
table.householdId, table.householdId,
@@ -662,6 +666,31 @@ export const paymentRecords = pgTable(
}) })
) )
export const paymentPurchaseAllocations = pgTable(
'payment_purchase_allocations',
{
id: uuid('id').defaultRandom().primaryKey(),
paymentRecordId: uuid('payment_record_id')
.notNull()
.references(() => paymentRecords.id, { onDelete: 'cascade' }),
purchaseId: uuid('purchase_id')
.notNull()
.references(() => purchaseMessages.id, { onDelete: 'cascade' }),
memberId: uuid('member_id')
.notNull()
.references(() => members.id, { onDelete: 'cascade' }),
amountMinor: bigint('amount_minor', { mode: 'bigint' }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
paymentIdx: index('payment_purchase_allocations_payment_idx').on(table.paymentRecordId),
purchaseMemberIdx: index('payment_purchase_allocations_purchase_member_idx').on(
table.purchaseId,
table.memberId
)
})
)
export const settlements = pgTable( export const settlements = pgTable(
'settlements', 'settlements',
{ {
@@ -732,4 +761,5 @@ export type TopicMessage = typeof topicMessages.$inferSelect
export type AnonymousMessage = typeof anonymousMessages.$inferSelect export type AnonymousMessage = typeof anonymousMessages.$inferSelect
export type PaymentConfirmation = typeof paymentConfirmations.$inferSelect export type PaymentConfirmation = typeof paymentConfirmations.$inferSelect
export type PaymentRecord = typeof paymentRecords.$inferSelect export type PaymentRecord = typeof paymentRecords.$inferSelect
export type PaymentPurchaseAllocation = typeof paymentPurchaseAllocations.$inferSelect
export type Settlement = typeof settlements.$inferSelect export type Settlement = typeof settlements.$inferSelect

View File

@@ -14,6 +14,12 @@ export interface FinanceCycleRecord {
currency: CurrencyCode currency: CurrencyCode
} }
export interface FinanceMemberOverduePaymentRecord {
kind: FinancePaymentKind
amountMinor: bigint
periods: readonly string[]
}
export interface FinanceCycleExchangeRateRecord { export interface FinanceCycleExchangeRateRecord {
cycleId: string cycleId: string
sourceCurrency: CurrencyCode sourceCurrency: CurrencyCode
@@ -30,6 +36,8 @@ export interface FinanceRentRuleRecord {
export interface FinanceParsedPurchaseRecord { export interface FinanceParsedPurchaseRecord {
id: string id: string
cycleId: string | null
cyclePeriod?: string | null
payerMemberId: string payerMemberId: string
amountMinor: bigint amountMinor: bigint
currency: CurrencyCode currency: CurrencyCode
@@ -44,6 +52,15 @@ export interface FinanceParsedPurchaseRecord {
}[] }[]
} }
export interface FinancePaymentPurchaseAllocationRecord {
id: string
paymentRecordId: string
purchaseId: string
memberId: string
amountMinor: bigint
recordedAt: Instant
}
export interface FinanceUtilityBillRecord { export interface FinanceUtilityBillRecord {
id: string id: string
billName: string billName: string
@@ -57,6 +74,8 @@ export type FinancePaymentKind = 'rent' | 'utilities'
export interface FinancePaymentRecord { export interface FinancePaymentRecord {
id: string id: string
cycleId: string
cyclePeriod?: string | null
memberId: string memberId: string
kind: FinancePaymentKind kind: FinancePaymentKind
amountMinor: bigint amountMinor: bigint
@@ -151,6 +170,7 @@ export interface SettlementSnapshotRecord {
export interface FinanceRepository { export interface FinanceRepository {
getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null> getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null>
listMembers(): Promise<readonly FinanceMemberRecord[]> listMembers(): Promise<readonly FinanceMemberRecord[]>
listCycles(): Promise<readonly FinanceCycleRecord[]>
getOpenCycle(): Promise<FinanceCycleRecord | null> getOpenCycle(): Promise<FinanceCycleRecord | null>
getCycleByPeriod(period: string): Promise<FinanceCycleRecord | null> getCycleByPeriod(period: string): Promise<FinanceCycleRecord | null>
getLatestCycle(): Promise<FinanceCycleRecord | null> getLatestCycle(): Promise<FinanceCycleRecord | null>
@@ -215,6 +235,15 @@ export interface FinanceRepository {
currency: CurrencyCode currency: CurrencyCode
recordedAt: Instant recordedAt: Instant
}): Promise<FinancePaymentRecord> }): Promise<FinancePaymentRecord>
getPaymentRecord(paymentId: string): Promise<FinancePaymentRecord | null>
replacePaymentPurchaseAllocations(input: {
paymentRecordId: string
allocations: readonly {
purchaseId: string
memberId: string
amountMinor: bigint
}[]
}): Promise<void>
updatePaymentRecord(input: { updatePaymentRecord(input: {
paymentId: string paymentId: string
memberId: string memberId: string
@@ -231,6 +260,8 @@ export interface FinanceRepository {
start: Instant, start: Instant,
end: Instant end: Instant
): Promise<readonly FinanceParsedPurchaseRecord[]> ): Promise<readonly FinanceParsedPurchaseRecord[]>
listParsedPurchases(): Promise<readonly FinanceParsedPurchaseRecord[]>
listPaymentPurchaseAllocations(): Promise<readonly FinancePaymentPurchaseAllocationRecord[]>
getSettlementSnapshotLines( getSettlementSnapshotLines(
cycleId: string cycleId: string
): Promise<readonly FinanceSettlementSnapshotLineRecord[]> ): Promise<readonly FinanceSettlementSnapshotLineRecord[]>

View File

@@ -252,6 +252,7 @@ export interface HouseholdConfigurationRepository {
householdId: string, householdId: string,
memberId: string memberId: string
): Promise<HouseholdMemberRecord | null> ): Promise<HouseholdMemberRecord | null>
demoteHouseholdAdmin(householdId: string, memberId: string): Promise<HouseholdMemberRecord | null>
updateHouseholdMemberRentShareWeight( updateHouseholdMemberRentShareWeight(
householdId: string, householdId: string,
memberId: string, memberId: string,

View File

@@ -45,11 +45,13 @@ export type {
} from './anonymous-feedback' } from './anonymous-feedback'
export type { export type {
FinanceCycleRecord, FinanceCycleRecord,
FinanceMemberOverduePaymentRecord,
FinanceCycleExchangeRateRecord, FinanceCycleExchangeRateRecord,
FinancePaymentConfirmationReviewReason, FinancePaymentConfirmationReviewReason,
FinancePaymentConfirmationSaveInput, FinancePaymentConfirmationSaveInput,
FinancePaymentConfirmationSaveResult, FinancePaymentConfirmationSaveResult,
FinancePaymentKind, FinancePaymentKind,
FinancePaymentPurchaseAllocationRecord,
FinancePaymentRecord, FinancePaymentRecord,
FinanceSettlementSnapshotLineRecord, FinanceSettlementSnapshotLineRecord,
FinanceMemberRecord, FinanceMemberRecord,