mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 22:44:03 +00:00
feat(miniapp): carry overdue billing and admin role flows
This commit is contained in:
@@ -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 () => [],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 () => [],
|
||||||
|
|||||||
@@ -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: []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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: []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => [],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 () => [],
|
||||||
|
|||||||
@@ -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: []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? ''}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
|
||||||
timezone: 'Asia/Tbilisi',
|
|
||||||
rentWarningDay: 17,
|
|
||||||
rentDueDay: 20,
|
|
||||||
utilitiesReminderDay: 3,
|
|
||||||
utilitiesDueDay: 4,
|
|
||||||
paymentBalanceAdjustmentPolicy: 'utilities',
|
|
||||||
rentPaymentDestinations: [
|
|
||||||
{
|
{
|
||||||
label: 'TBC card',
|
label: 'Landlord TBC card',
|
||||||
recipientName: 'Landlord',
|
recipientName: 'Nana Beridze',
|
||||||
bankName: 'TBC Bank',
|
bankName: 'TBC Bank',
|
||||||
account: '1234 5678 9012 3456',
|
account: '1234 5678 9012 3456',
|
||||||
note: null,
|
note: 'Message: Kojori House rent',
|
||||||
link: null
|
link: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'USD fallback transfer',
|
||||||
|
recipientName: 'Nana Beridze',
|
||||||
|
bankName: 'Bank of Georgia',
|
||||||
|
account: 'GE29BG0000000123456789',
|
||||||
|
note: 'Use only if GEL transfer is unavailable',
|
||||||
|
link: 'https://bank.example/rent'
|
||||||
}
|
}
|
||||||
],
|
] 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
|
||||||
|
|||||||
@@ -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:
|
||||||
'Настройками дома и подтверждением заявок управляют админы. Свой язык можно менять переключателем выше.',
|
'Настройками дома и подтверждением заявок управляют админы. Свой язык можно менять переключателем выше.',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
@@ -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
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -614,9 +645,16 @@ export default function LedgerRoute() {
|
|||||||
<Show
|
<Show
|
||||||
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 style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
|
||||||
|
<div>
|
||||||
|
<strong>{copy().unresolvedPurchasesTitle}</strong>
|
||||||
|
<Show
|
||||||
|
when={unresolvedPurchaseLedger().length > 0}
|
||||||
|
fallback={<p class="empty-state">{copy().unresolvedPurchasesEmpty}</p>}
|
||||||
>
|
>
|
||||||
<div class="editable-list">
|
<div class="editable-list">
|
||||||
<For each={purchaseLedger()}>
|
<For each={unresolvedPurchaseLedger()}>
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
<button
|
<button
|
||||||
class="editable-list-row"
|
class="editable-list-row"
|
||||||
@@ -626,14 +664,18 @@ export default function LedgerRoute() {
|
|||||||
<div class="editable-list-row__main">
|
<div class="editable-list-row__main">
|
||||||
<span class="editable-list-row__title">{entry.title}</span>
|
<span class="editable-list-row__title">{entry.title}</span>
|
||||||
<span class="editable-list-row__subtitle">
|
<span class="editable-list-row__subtitle">
|
||||||
{entry.actorDisplayName}
|
{[entry.actorDisplayName, entry.originPeriod, 'Unresolved']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editable-list-row__meta">
|
<div class="editable-list-row__meta">
|
||||||
<strong>{ledgerPrimaryAmount(entry)}</strong>
|
<strong>{ledgerPrimaryAmount(entry)}</strong>
|
||||||
<Show when={ledgerSecondaryAmount(entry)}>
|
<Show when={ledgerSecondaryAmount(entry)}>
|
||||||
{(secondary) => (
|
{(secondary) => (
|
||||||
<span class="editable-list-row__secondary">{secondary()}</span>
|
<span class="editable-list-row__secondary">
|
||||||
|
{secondary()}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -642,6 +684,48 @@ export default function LedgerRoute() {
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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>
|
||||||
|
</Show>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{/* ── Utility bills ──────────────────────── */}
|
{/* ── Utility bills ──────────────────────── */}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
]}
|
||||||
|
onChange={(value) => setEditMemberForm((f) => ({ ...f, isAdmin: value === 'admin' }))}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,7 +845,12 @@ async function buildFinanceDashboard(
|
|||||||
participatesInPurchases: member.status === 'active',
|
participatesInPurchases: member.status === 'active',
|
||||||
rentWeight: member.rentShareWeight
|
rentWeight: member.rentShareWeight
|
||||||
})),
|
})),
|
||||||
purchases: convertedPurchases.map(({ purchase, converted }) => {
|
purchases: purchaseHistory
|
||||||
|
.filter(
|
||||||
|
({ purchase, outstandingTotal }) =>
|
||||||
|
currentCyclePurchaseIds.has(purchase.id) || outstandingTotal.amountMinor > 0n
|
||||||
|
)
|
||||||
|
.map(({ purchase, converted, outstandingByMemberId, outstandingTotal }) => {
|
||||||
const nextPurchase: {
|
const nextPurchase: {
|
||||||
purchaseId: PurchaseEntryId
|
purchaseId: PurchaseEntryId
|
||||||
payerId: MemberId
|
payerId: MemberId
|
||||||
@@ -420,25 +863,28 @@ async function buildFinanceDashboard(
|
|||||||
} = {
|
} = {
|
||||||
purchaseId: PurchaseEntryId.from(purchase.id),
|
purchaseId: PurchaseEntryId.from(purchase.id),
|
||||||
payerId: MemberId.from(purchase.payerMemberId),
|
payerId: MemberId.from(purchase.payerMemberId),
|
||||||
amount: converted.settlementAmount,
|
amount: currentCyclePurchaseIds.has(purchase.id)
|
||||||
splitMode: purchase.splitMode ?? 'equal'
|
? 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
|
||||||
})
|
})
|
||||||
@@ -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 paymentTargets = periodArg
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
cycle: currentCycle,
|
||||||
|
baseRemainingMinor: 0n,
|
||||||
|
allowOverflow: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: 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({
|
const payment = await repository.addPaymentRecord({
|
||||||
cycleId: openCycle.id,
|
cycleId: target.cycle.id,
|
||||||
memberId,
|
memberId,
|
||||||
kind,
|
kind,
|
||||||
amountMinor: amount.amountMinor,
|
amountMinor,
|
||||||
currency,
|
currency,
|
||||||
recordedAt: nowInstant()
|
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,
|
||||||
|
|||||||
@@ -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 () => [],
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 () => [],
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
packages/db/drizzle/0022_carry_purchase_history.sql
Normal file
34
packages/db/drizzle/0022_carry_purchase_history.sql
Normal 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");
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[]>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user