mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:54:02 +00:00
feat(miniapp): improve mobile billing and utility controls
This commit is contained in:
@@ -49,8 +49,10 @@ import {
|
|||||||
createMiniAppAddUtilityBillHandler,
|
createMiniAppAddUtilityBillHandler,
|
||||||
createMiniAppBillingCycleHandler,
|
createMiniAppBillingCycleHandler,
|
||||||
createMiniAppCloseCycleHandler,
|
createMiniAppCloseCycleHandler,
|
||||||
|
createMiniAppDeleteUtilityBillHandler,
|
||||||
createMiniAppOpenCycleHandler,
|
createMiniAppOpenCycleHandler,
|
||||||
createMiniAppRentUpdateHandler
|
createMiniAppRentUpdateHandler,
|
||||||
|
createMiniAppUpdateUtilityBillHandler
|
||||||
} from './miniapp-billing'
|
} from './miniapp-billing'
|
||||||
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
|
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
|
||||||
import { createNbgExchangeRateProvider } from './nbg-exchange-rates'
|
import { createNbgExchangeRateProvider } from './nbg-exchange-rates'
|
||||||
@@ -463,6 +465,24 @@ const server = createBotWebhookServer({
|
|||||||
logger: getLogger('miniapp-billing')
|
logger: getLogger('miniapp-billing')
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
|
miniAppUpdateUtilityBill: householdOnboardingService
|
||||||
|
? createMiniAppUpdateUtilityBillHandler({
|
||||||
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
|
botToken: runtime.telegramBotToken,
|
||||||
|
onboardingService: householdOnboardingService,
|
||||||
|
financeServiceForHousehold,
|
||||||
|
logger: getLogger('miniapp-billing')
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
miniAppDeleteUtilityBill: householdOnboardingService
|
||||||
|
? createMiniAppDeleteUtilityBillHandler({
|
||||||
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
|
botToken: runtime.telegramBotToken,
|
||||||
|
onboardingService: householdOnboardingService,
|
||||||
|
financeServiceForHousehold,
|
||||||
|
logger: getLogger('miniapp-billing')
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
miniAppLocalePreference: householdOnboardingService
|
miniAppLocalePreference: householdOnboardingService
|
||||||
? createMiniAppLocalePreferenceHandler({
|
? createMiniAppLocalePreferenceHandler({
|
||||||
allowedOrigins: runtime.miniAppAllowedOrigins,
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
|
|||||||
@@ -449,6 +449,11 @@ export function createMiniAppUpdateSettingsHandler(options: {
|
|||||||
const result = await options.miniAppAdminService.updateSettings({
|
const result = await options.miniAppAdminService.updateSettings({
|
||||||
householdId: session.member.householdId,
|
householdId: session.member.householdId,
|
||||||
actorIsAdmin: session.member.isAdmin,
|
actorIsAdmin: session.member.isAdmin,
|
||||||
|
...(payload.settlementCurrency
|
||||||
|
? {
|
||||||
|
settlementCurrency: payload.settlementCurrency
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(payload.rentAmountMajor !== undefined
|
...(payload.rentAmountMajor !== undefined
|
||||||
? {
|
? {
|
||||||
rentAmountMajor: payload.rentAmountMajor
|
rentAmountMajor: payload.rentAmountMajor
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import type {
|
|||||||
import {
|
import {
|
||||||
createMiniAppAddUtilityBillHandler,
|
createMiniAppAddUtilityBillHandler,
|
||||||
createMiniAppBillingCycleHandler,
|
createMiniAppBillingCycleHandler,
|
||||||
|
createMiniAppDeleteUtilityBillHandler,
|
||||||
createMiniAppOpenCycleHandler,
|
createMiniAppOpenCycleHandler,
|
||||||
createMiniAppRentUpdateHandler
|
createMiniAppRentUpdateHandler,
|
||||||
|
createMiniAppUpdateUtilityBillHandler
|
||||||
} from './miniapp-billing'
|
} from './miniapp-billing'
|
||||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||||
|
|
||||||
@@ -179,6 +181,12 @@ function createFinanceServiceStub(): FinanceCommandService {
|
|||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
period: '2026-03'
|
period: '2026-03'
|
||||||
}),
|
}),
|
||||||
|
updateUtilityBill: async () => ({
|
||||||
|
billId: 'utility-1',
|
||||||
|
amount: Money.fromMinor(4500n, 'USD'),
|
||||||
|
currency: 'USD'
|
||||||
|
}),
|
||||||
|
deleteUtilityBill: async () => true,
|
||||||
generateDashboard: async () => null,
|
generateDashboard: async () => null,
|
||||||
generateStatement: async () => null
|
generateStatement: async () => null
|
||||||
}
|
}
|
||||||
@@ -249,6 +257,87 @@ describe('createMiniAppBillingCycleHandler', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('createMiniAppUpdateUtilityBillHandler updates a utility bill for the current cycle', async () => {
|
||||||
|
const repository = onboardingRepository()
|
||||||
|
const handler = createMiniAppUpdateUtilityBillHandler({
|
||||||
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
|
botToken: 'test-bot-token',
|
||||||
|
onboardingService: createHouseholdOnboardingService({
|
||||||
|
repository
|
||||||
|
}),
|
||||||
|
financeServiceForHousehold: () => createFinanceServiceStub()
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await handler.handler(
|
||||||
|
new Request('http://localhost/api/miniapp/admin/utility-bills/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
origin: 'http://localhost:5173',
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: initData(),
|
||||||
|
billId: 'utility-1',
|
||||||
|
billName: 'Electricity',
|
||||||
|
amountMajor: '45.00',
|
||||||
|
currency: 'GEL'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
authorized: true,
|
||||||
|
cycleState: {
|
||||||
|
utilityBills: [
|
||||||
|
{
|
||||||
|
id: 'utility-1'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('createMiniAppDeleteUtilityBillHandler deletes a utility bill for the current cycle', async () => {
|
||||||
|
const repository = onboardingRepository()
|
||||||
|
const handler = createMiniAppDeleteUtilityBillHandler({
|
||||||
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
|
botToken: 'test-bot-token',
|
||||||
|
onboardingService: createHouseholdOnboardingService({
|
||||||
|
repository
|
||||||
|
}),
|
||||||
|
financeServiceForHousehold: () => createFinanceServiceStub()
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await handler.handler(
|
||||||
|
new Request('http://localhost/api/miniapp/admin/utility-bills/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
origin: 'http://localhost:5173',
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: initData(),
|
||||||
|
billId: 'utility-1'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
authorized: true,
|
||||||
|
cycleState: {
|
||||||
|
utilityBills: [
|
||||||
|
{
|
||||||
|
id: 'utility-1'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('createMiniAppOpenCycleHandler', () => {
|
describe('createMiniAppOpenCycleHandler', () => {
|
||||||
test('opens a billing cycle for an authenticated admin', async () => {
|
test('opens a billing cycle for an authenticated admin', async () => {
|
||||||
const repository = onboardingRepository()
|
const repository = onboardingRepository()
|
||||||
|
|||||||
@@ -217,6 +217,72 @@ async function readUtilityBillPayload(request: Request): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readUtilityBillUpdatePayload(request: Request): Promise<{
|
||||||
|
initData: string
|
||||||
|
billId: string
|
||||||
|
billName: string
|
||||||
|
amountMajor: string
|
||||||
|
currency?: string
|
||||||
|
}> {
|
||||||
|
const parsed = await parseJsonBody<{
|
||||||
|
initData?: string
|
||||||
|
billId?: string
|
||||||
|
billName?: string
|
||||||
|
amountMajor?: string
|
||||||
|
currency?: string
|
||||||
|
}>(request)
|
||||||
|
const initData = parsed.initData?.trim()
|
||||||
|
if (!initData) {
|
||||||
|
throw new Error('Missing initData')
|
||||||
|
}
|
||||||
|
const billId = parsed.billId?.trim()
|
||||||
|
const billName = parsed.billName?.trim()
|
||||||
|
const amountMajor = parsed.amountMajor?.trim()
|
||||||
|
|
||||||
|
if (!billId) {
|
||||||
|
throw new Error('Missing utility bill id')
|
||||||
|
}
|
||||||
|
if (!billName) {
|
||||||
|
throw new Error('Missing utility bill name')
|
||||||
|
}
|
||||||
|
if (!amountMajor) {
|
||||||
|
throw new Error('Missing utility bill amount')
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = parsed.currency?.trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
initData,
|
||||||
|
billId,
|
||||||
|
billName,
|
||||||
|
amountMajor,
|
||||||
|
...(currency ? { currency } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUtilityBillDeletePayload(request: Request): Promise<{
|
||||||
|
initData: string
|
||||||
|
billId: string
|
||||||
|
}> {
|
||||||
|
const parsed = await parseJsonBody<{
|
||||||
|
initData?: string
|
||||||
|
billId?: string
|
||||||
|
}>(request)
|
||||||
|
const initData = parsed.initData?.trim()
|
||||||
|
if (!initData) {
|
||||||
|
throw new Error('Missing initData')
|
||||||
|
}
|
||||||
|
const billId = parsed.billId?.trim()
|
||||||
|
if (!billId) {
|
||||||
|
throw new Error('Missing utility bill id')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initData,
|
||||||
|
billId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createMiniAppBillingCycleHandler(options: {
|
export function createMiniAppBillingCycleHandler(options: {
|
||||||
allowedOrigins: readonly string[]
|
allowedOrigins: readonly string[]
|
||||||
botToken: string
|
botToken: string
|
||||||
@@ -523,3 +589,132 @@ export function createMiniAppAddUtilityBillHandler(options: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createMiniAppUpdateUtilityBillHandler(options: {
|
||||||
|
allowedOrigins: readonly string[]
|
||||||
|
botToken: string
|
||||||
|
financeServiceForHousehold: (householdId: string) => FinanceCommandService
|
||||||
|
onboardingService: HouseholdOnboardingService
|
||||||
|
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 auth = await authenticateAdminSession(
|
||||||
|
request.clone() as Request,
|
||||||
|
sessionService,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
if (auth instanceof Response) {
|
||||||
|
return auth
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await readUtilityBillUpdatePayload(request)
|
||||||
|
const service = options.financeServiceForHousehold(auth.member.householdId)
|
||||||
|
const result = await service.updateUtilityBill(
|
||||||
|
payload.billId,
|
||||||
|
payload.billName,
|
||||||
|
payload.amountMajor,
|
||||||
|
payload.currency
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return miniAppJsonResponse({ ok: false, error: 'Utility bill not found' }, 404, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleState = await service.getAdminCycleState()
|
||||||
|
|
||||||
|
return miniAppJsonResponse(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
authorized: true,
|
||||||
|
cycleState: serializeCycleState(cycleState)
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
return miniAppErrorResponse(error, origin, options.logger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMiniAppDeleteUtilityBillHandler(options: {
|
||||||
|
allowedOrigins: readonly string[]
|
||||||
|
botToken: string
|
||||||
|
financeServiceForHousehold: (householdId: string) => FinanceCommandService
|
||||||
|
onboardingService: HouseholdOnboardingService
|
||||||
|
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 auth = await authenticateAdminSession(
|
||||||
|
request.clone() as Request,
|
||||||
|
sessionService,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
if (auth instanceof Response) {
|
||||||
|
return auth
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await readUtilityBillDeletePayload(request)
|
||||||
|
const service = options.financeServiceForHousehold(auth.member.householdId)
|
||||||
|
const deleted = await service.deleteUtilityBill(payload.billId)
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return miniAppJsonResponse({ ok: false, error: 'Utility bill not found' }, 404, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleState = await service.getAdminCycleState()
|
||||||
|
|
||||||
|
return miniAppJsonResponse(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
authorized: true,
|
||||||
|
cycleState: serializeCycleState(cycleState)
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
return miniAppErrorResponse(error, origin, options.logger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ function repository(
|
|||||||
getCycleExchangeRate: async () => null,
|
getCycleExchangeRate: async () => null,
|
||||||
saveCycleExchangeRate: async (input) => input,
|
saveCycleExchangeRate: async (input) => input,
|
||||||
addUtilityBill: async () => {},
|
addUtilityBill: async () => {},
|
||||||
|
updateUtilityBill: async () => null,
|
||||||
|
deleteUtilityBill: async () => false,
|
||||||
getRentRuleForPeriod: async () => ({
|
getRentRuleForPeriod: async () => ({
|
||||||
amountMinor: 70000n,
|
amountMinor: 70000n,
|
||||||
currency: 'USD'
|
currency: 'USD'
|
||||||
|
|||||||
@@ -127,6 +127,24 @@ describe('createBotWebhookServer', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
miniAppUpdateUtilityBill: {
|
||||||
|
handler: async () =>
|
||||||
|
new Response(JSON.stringify({ ok: true, authorized: true, cycleState: {} }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json; charset=utf-8'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
miniAppDeleteUtilityBill: {
|
||||||
|
handler: async () =>
|
||||||
|
new Response(JSON.stringify({ ok: true, authorized: true, cycleState: {} }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json; charset=utf-8'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
miniAppApproveMember: {
|
miniAppApproveMember: {
|
||||||
handler: async () =>
|
handler: async () =>
|
||||||
new Response(JSON.stringify({ ok: true, authorized: true, member: {} }), {
|
new Response(JSON.stringify({ ok: true, authorized: true, member: {} }), {
|
||||||
@@ -409,6 +427,38 @@ describe('createBotWebhookServer', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('accepts mini app utility bill update request', async () => {
|
||||||
|
const response = await server.fetch(
|
||||||
|
new Request('http://localhost/api/miniapp/admin/utility-bills/update', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ initData: 'payload', billId: 'utility-1' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
ok: true,
|
||||||
|
authorized: true,
|
||||||
|
cycleState: {}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepts mini app utility bill delete request', async () => {
|
||||||
|
const response = await server.fetch(
|
||||||
|
new Request('http://localhost/api/miniapp/admin/utility-bills/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ initData: 'payload', billId: 'utility-1' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
ok: true,
|
||||||
|
authorized: true,
|
||||||
|
cycleState: {}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('accepts mini app approve member request', async () => {
|
test('accepts mini app approve member request', async () => {
|
||||||
const response = await server.fetch(
|
const response = await server.fetch(
|
||||||
new Request('http://localhost/api/miniapp/admin/approve-member', {
|
new Request('http://localhost/api/miniapp/admin/approve-member', {
|
||||||
|
|||||||
@@ -92,6 +92,18 @@ export interface BotWebhookServerOptions {
|
|||||||
handler: (request: Request) => Promise<Response>
|
handler: (request: Request) => Promise<Response>
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
|
miniAppUpdateUtilityBill?:
|
||||||
|
| {
|
||||||
|
path?: string
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
miniAppDeleteUtilityBill?:
|
||||||
|
| {
|
||||||
|
path?: string
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
miniAppLocalePreference?:
|
miniAppLocalePreference?:
|
||||||
| {
|
| {
|
||||||
path?: string
|
path?: string
|
||||||
@@ -153,6 +165,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
const miniAppRentUpdatePath = options.miniAppRentUpdate?.path ?? '/api/miniapp/admin/rent/update'
|
const miniAppRentUpdatePath = options.miniAppRentUpdate?.path ?? '/api/miniapp/admin/rent/update'
|
||||||
const miniAppAddUtilityBillPath =
|
const miniAppAddUtilityBillPath =
|
||||||
options.miniAppAddUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/add'
|
options.miniAppAddUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/add'
|
||||||
|
const miniAppUpdateUtilityBillPath =
|
||||||
|
options.miniAppUpdateUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/update'
|
||||||
|
const miniAppDeleteUtilityBillPath =
|
||||||
|
options.miniAppDeleteUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/delete'
|
||||||
const miniAppLocalePreferencePath =
|
const miniAppLocalePreferencePath =
|
||||||
options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale'
|
options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale'
|
||||||
const schedulerPathPrefix = options.scheduler
|
const schedulerPathPrefix = options.scheduler
|
||||||
@@ -233,6 +249,14 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
return await options.miniAppAddUtilityBill.handler(request)
|
return await options.miniAppAddUtilityBill.handler(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.miniAppUpdateUtilityBill && url.pathname === miniAppUpdateUtilityBillPath) {
|
||||||
|
return await options.miniAppUpdateUtilityBill.handler(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.miniAppDeleteUtilityBill && url.pathname === miniAppDeleteUtilityBillPath) {
|
||||||
|
return await options.miniAppDeleteUtilityBill.handler(request)
|
||||||
|
}
|
||||||
|
|
||||||
if (options.miniAppLocalePreference && url.pathname === miniAppLocalePreferencePath) {
|
if (options.miniAppLocalePreference && url.pathname === miniAppLocalePreferencePath) {
|
||||||
return await options.miniAppLocalePreference.handler(request)
|
return await options.miniAppLocalePreference.handler(request)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
addMiniAppUtilityBill,
|
addMiniAppUtilityBill,
|
||||||
approveMiniAppPendingMember,
|
approveMiniAppPendingMember,
|
||||||
closeMiniAppBillingCycle,
|
closeMiniAppBillingCycle,
|
||||||
|
deleteMiniAppUtilityBill,
|
||||||
fetchMiniAppAdminSettings,
|
fetchMiniAppAdminSettings,
|
||||||
fetchMiniAppBillingCycle,
|
fetchMiniAppBillingCycle,
|
||||||
fetchMiniAppDashboard,
|
fetchMiniAppDashboard,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
updateMiniAppBillingSettings,
|
updateMiniAppBillingSettings,
|
||||||
updateMiniAppCycleRent,
|
updateMiniAppCycleRent,
|
||||||
upsertMiniAppUtilityCategory,
|
upsertMiniAppUtilityCategory,
|
||||||
|
updateMiniAppUtilityBill,
|
||||||
type MiniAppDashboard,
|
type MiniAppDashboard,
|
||||||
type MiniAppPendingMember
|
type MiniAppPendingMember
|
||||||
} from './miniapp-api'
|
} from './miniapp-api'
|
||||||
@@ -62,6 +64,12 @@ type SessionState =
|
|||||||
|
|
||||||
type NavigationKey = 'home' | 'balances' | 'ledger' | 'house'
|
type NavigationKey = 'home' | 'balances' | 'ledger' | 'house'
|
||||||
|
|
||||||
|
type UtilityBillDraft = {
|
||||||
|
billName: string
|
||||||
|
amountMajor: string
|
||||||
|
currency: 'USD' | 'GEL'
|
||||||
|
}
|
||||||
|
|
||||||
const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
mode: 'demo',
|
mode: 'demo',
|
||||||
@@ -186,6 +194,21 @@ function ledgerSecondaryAmount(entry: MiniAppDashboard['ledger'][number]): strin
|
|||||||
return `${entry.amountMajor} ${entry.currency}`
|
return `${entry.amountMajor} ${entry.currency}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cycleUtilityBillDrafts(
|
||||||
|
bills: MiniAppAdminCycleState['utilityBills']
|
||||||
|
): Record<string, UtilityBillDraft> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
bills.map((bill) => [
|
||||||
|
bill.id,
|
||||||
|
{
|
||||||
|
billName: bill.billName,
|
||||||
|
amountMajor: minorToMajorString(BigInt(bill.amountMinor)),
|
||||||
|
currency: bill.currency
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [locale, setLocale] = createSignal<Locale>('en')
|
const [locale, setLocale] = createSignal<Locale>('en')
|
||||||
const [session, setSession] = createSignal<SessionState>({
|
const [session, setSession] = createSignal<SessionState>({
|
||||||
@@ -209,6 +232,11 @@ function App() {
|
|||||||
const [closingCycle, setClosingCycle] = createSignal(false)
|
const [closingCycle, setClosingCycle] = createSignal(false)
|
||||||
const [savingCycleRent, setSavingCycleRent] = createSignal(false)
|
const [savingCycleRent, setSavingCycleRent] = createSignal(false)
|
||||||
const [savingUtilityBill, setSavingUtilityBill] = createSignal(false)
|
const [savingUtilityBill, setSavingUtilityBill] = createSignal(false)
|
||||||
|
const [savingUtilityBillId, setSavingUtilityBillId] = createSignal<string | null>(null)
|
||||||
|
const [deletingUtilityBillId, setDeletingUtilityBillId] = createSignal<string | null>(null)
|
||||||
|
const [utilityBillDrafts, setUtilityBillDrafts] = createSignal<Record<string, UtilityBillDraft>>(
|
||||||
|
{}
|
||||||
|
)
|
||||||
const [billingForm, setBillingForm] = createSignal({
|
const [billingForm, setBillingForm] = createSignal({
|
||||||
settlementCurrency: 'GEL' as 'USD' | 'GEL',
|
settlementCurrency: 'GEL' as 'USD' | 'GEL',
|
||||||
rentAmountMajor: '',
|
rentAmountMajor: '',
|
||||||
@@ -222,7 +250,8 @@ function App() {
|
|||||||
const [newCategoryName, setNewCategoryName] = createSignal('')
|
const [newCategoryName, setNewCategoryName] = createSignal('')
|
||||||
const [cycleForm, setCycleForm] = createSignal({
|
const [cycleForm, setCycleForm] = createSignal({
|
||||||
period: defaultCyclePeriod(),
|
period: defaultCyclePeriod(),
|
||||||
currency: 'GEL' as 'USD' | 'GEL',
|
rentCurrency: 'USD' as 'USD' | 'GEL',
|
||||||
|
utilityCurrency: 'GEL' as 'USD' | 'GEL',
|
||||||
rentAmountMajor: '',
|
rentAmountMajor: '',
|
||||||
utilityCategorySlug: '',
|
utilityCategorySlug: '',
|
||||||
utilityAmountMajor: ''
|
utilityAmountMajor: ''
|
||||||
@@ -320,7 +349,8 @@ function App() {
|
|||||||
)
|
)
|
||||||
setCycleForm((current) => ({
|
setCycleForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
currency: current.currency || payload.settings.settlementCurrency,
|
rentCurrency: payload.settings.rentCurrency,
|
||||||
|
utilityCurrency: payload.settings.settlementCurrency,
|
||||||
utilityCategorySlug:
|
utilityCategorySlug:
|
||||||
current.utilityCategorySlug ||
|
current.utilityCategorySlug ||
|
||||||
payload.categories.find((category) => category.isActive)?.slug ||
|
payload.categories.find((category) => category.isActive)?.slug ||
|
||||||
@@ -351,13 +381,15 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const payload = await fetchMiniAppBillingCycle(initData)
|
const payload = await fetchMiniAppBillingCycle(initData)
|
||||||
setCycleState(payload)
|
setCycleState(payload)
|
||||||
|
setUtilityBillDrafts(cycleUtilityBillDrafts(payload.utilityBills))
|
||||||
setCycleForm((current) => ({
|
setCycleForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
period: payload.cycle?.period ?? current.period,
|
period: payload.cycle?.period ?? current.period,
|
||||||
currency:
|
rentCurrency:
|
||||||
payload.cycle?.currency ??
|
payload.rentRule?.currency ??
|
||||||
adminSettings()?.settings.settlementCurrency ??
|
adminSettings()?.settings.rentCurrency ??
|
||||||
current.currency,
|
current.rentCurrency,
|
||||||
|
utilityCurrency: adminSettings()?.settings.settlementCurrency ?? current.utilityCurrency,
|
||||||
rentAmountMajor: payload.rentRule
|
rentAmountMajor: payload.rentRule
|
||||||
? (Number(payload.rentRule.amountMinor) / 100).toFixed(2)
|
? (Number(payload.rentRule.amountMinor) / 100).toFixed(2)
|
||||||
: '',
|
: '',
|
||||||
@@ -707,7 +739,8 @@ function App() {
|
|||||||
)
|
)
|
||||||
setCycleForm((current) => ({
|
setCycleForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
currency: cycleState()?.cycle?.currency ?? settings.settlementCurrency
|
rentCurrency: settings.rentCurrency,
|
||||||
|
utilityCurrency: settings.settlementCurrency
|
||||||
}))
|
}))
|
||||||
} finally {
|
} finally {
|
||||||
setSavingBillingSettings(false)
|
setSavingBillingSettings(false)
|
||||||
@@ -726,13 +759,14 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const state = await openMiniAppBillingCycle(initData, {
|
const state = await openMiniAppBillingCycle(initData, {
|
||||||
period: cycleForm().period,
|
period: cycleForm().period,
|
||||||
currency: cycleForm().currency
|
currency: billingForm().settlementCurrency
|
||||||
})
|
})
|
||||||
setCycleState(state)
|
setCycleState(state)
|
||||||
|
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
||||||
setCycleForm((current) => ({
|
setCycleForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
period: state.cycle?.period ?? current.period,
|
period: state.cycle?.period ?? current.period,
|
||||||
currency: state.cycle?.currency ?? current.currency
|
utilityCurrency: billingForm().settlementCurrency
|
||||||
}))
|
}))
|
||||||
} finally {
|
} finally {
|
||||||
setOpeningCycle(false)
|
setOpeningCycle(false)
|
||||||
@@ -751,6 +785,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const state = await closeMiniAppBillingCycle(initData, cycleState()?.cycle?.period)
|
const state = await closeMiniAppBillingCycle(initData, cycleState()?.cycle?.period)
|
||||||
setCycleState(state)
|
setCycleState(state)
|
||||||
|
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
||||||
} finally {
|
} finally {
|
||||||
setClosingCycle(false)
|
setClosingCycle(false)
|
||||||
}
|
}
|
||||||
@@ -768,7 +803,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const state = await updateMiniAppCycleRent(initData, {
|
const state = await updateMiniAppCycleRent(initData, {
|
||||||
amountMajor: cycleForm().rentAmountMajor,
|
amountMajor: cycleForm().rentAmountMajor,
|
||||||
currency: cycleForm().currency,
|
currency: cycleForm().rentCurrency,
|
||||||
...(cycleState()?.cycle?.period
|
...(cycleState()?.cycle?.period
|
||||||
? {
|
? {
|
||||||
period: cycleState()!.cycle!.period
|
period: cycleState()!.cycle!.period
|
||||||
@@ -776,6 +811,7 @@ function App() {
|
|||||||
: {})
|
: {})
|
||||||
})
|
})
|
||||||
setCycleState(state)
|
setCycleState(state)
|
||||||
|
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
||||||
} finally {
|
} finally {
|
||||||
setSavingCycleRent(false)
|
setSavingCycleRent(false)
|
||||||
}
|
}
|
||||||
@@ -803,9 +839,10 @@ function App() {
|
|||||||
const state = await addMiniAppUtilityBill(initData, {
|
const state = await addMiniAppUtilityBill(initData, {
|
||||||
billName: selectedCategory.name,
|
billName: selectedCategory.name,
|
||||||
amountMajor: cycleForm().utilityAmountMajor,
|
amountMajor: cycleForm().utilityAmountMajor,
|
||||||
currency: cycleForm().currency
|
currency: cycleForm().utilityCurrency
|
||||||
})
|
})
|
||||||
setCycleState(state)
|
setCycleState(state)
|
||||||
|
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
||||||
setCycleForm((current) => ({
|
setCycleForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
utilityAmountMajor: ''
|
utilityAmountMajor: ''
|
||||||
@@ -815,6 +852,56 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUpdateUtilityBill(billId: string) {
|
||||||
|
const initData = webApp?.initData?.trim()
|
||||||
|
const currentReady = readySession()
|
||||||
|
const draft = utilityBillDrafts()[billId]
|
||||||
|
|
||||||
|
if (
|
||||||
|
!initData ||
|
||||||
|
currentReady?.mode !== 'live' ||
|
||||||
|
!currentReady.member.isAdmin ||
|
||||||
|
!draft ||
|
||||||
|
draft.billName.trim().length === 0 ||
|
||||||
|
draft.amountMajor.trim().length === 0
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingUtilityBillId(billId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await updateMiniAppUtilityBill(initData, {
|
||||||
|
billId,
|
||||||
|
billName: draft.billName,
|
||||||
|
amountMajor: draft.amountMajor,
|
||||||
|
currency: draft.currency
|
||||||
|
})
|
||||||
|
setCycleState(state)
|
||||||
|
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
||||||
|
} finally {
|
||||||
|
setSavingUtilityBillId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUtilityBill(billId: string) {
|
||||||
|
const initData = webApp?.initData?.trim()
|
||||||
|
const currentReady = readySession()
|
||||||
|
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingUtilityBillId(billId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await deleteMiniAppUtilityBill(initData, billId)
|
||||||
|
setCycleState(state)
|
||||||
|
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
||||||
|
} finally {
|
||||||
|
setDeletingUtilityBillId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSaveUtilityCategory(input: {
|
async function handleSaveUtilityCategory(input: {
|
||||||
slug?: string
|
slug?: string
|
||||||
name: string
|
name: string
|
||||||
@@ -1138,7 +1225,7 @@ function App() {
|
|||||||
<p>
|
<p>
|
||||||
{copy().billingCycleStatus.replace(
|
{copy().billingCycleStatus.replace(
|
||||||
'{currency}',
|
'{currency}',
|
||||||
cycleState()?.cycle?.currency ?? cycleForm().currency
|
cycleState()?.cycle?.currency ?? billingForm().settlementCurrency
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<Show when={dashboard()}>
|
<Show when={dashboard()}>
|
||||||
@@ -1168,11 +1255,11 @@ function App() {
|
|||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
<span>{copy().shareRent}</span>
|
<span>{copy().shareRent}</span>
|
||||||
<select
|
<select
|
||||||
value={cycleForm().currency}
|
value={cycleForm().rentCurrency}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setCycleForm((current) => ({
|
setCycleForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
currency: event.currentTarget.value as 'USD' | 'GEL'
|
rentCurrency: event.currentTarget.value as 'USD' | 'GEL'
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -1218,21 +1305,12 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="settings-field">
|
<div class="settings-field">
|
||||||
<span>{copy().settlementCurrency}</span>
|
<span>{copy().settlementCurrency}</span>
|
||||||
<select
|
<div class="settings-field__value">
|
||||||
value={cycleForm().currency}
|
{billingForm().settlementCurrency}
|
||||||
onChange={(event) =>
|
</div>
|
||||||
setCycleForm((current) => ({
|
</div>
|
||||||
...current,
|
|
||||||
currency: event.currentTarget.value as 'USD' | 'GEL'
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="USD">USD</option>
|
|
||||||
<option value="GEL">GEL</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="ghost-button"
|
class="ghost-button"
|
||||||
@@ -1447,7 +1525,7 @@ function App() {
|
|||||||
<article class="balance-item">
|
<article class="balance-item">
|
||||||
<header>
|
<header>
|
||||||
<strong>{copy().utilityLedgerTitle}</strong>
|
<strong>{copy().utilityLedgerTitle}</strong>
|
||||||
<span>{cycleState()?.cycle?.currency ?? billingForm().settlementCurrency}</span>
|
<span>{cycleForm().utilityCurrency}</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
@@ -1480,6 +1558,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
<span>{copy().settlementCurrency}</span>
|
||||||
|
<select
|
||||||
|
value={cycleForm().utilityCurrency}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCycleForm((current) => ({
|
||||||
|
...current,
|
||||||
|
utilityCurrency: event.currentTarget.value as 'USD' | 'GEL'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="GEL">GEL</option>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="ghost-button"
|
class="ghost-button"
|
||||||
@@ -1491,17 +1584,103 @@ function App() {
|
|||||||
>
|
>
|
||||||
{savingUtilityBill() ? copy().savingUtilityBill : copy().addUtilityBillAction}
|
{savingUtilityBill() ? copy().savingUtilityBill : copy().addUtilityBillAction}
|
||||||
</button>
|
</button>
|
||||||
<div class="balance-list admin-sublist">
|
<div class="admin-sublist admin-sublist--plain">
|
||||||
{cycleState()?.utilityBills.length ? (
|
{cycleState()?.utilityBills.length ? (
|
||||||
cycleState()?.utilityBills.map((bill) => (
|
cycleState()?.utilityBills.map((bill) => (
|
||||||
<article class="ledger-item">
|
<article class="utility-bill-row">
|
||||||
<header>
|
<header>
|
||||||
<strong>{bill.billName}</strong>
|
<strong>
|
||||||
<span>
|
{utilityBillDrafts()[bill.id]?.billName ?? bill.billName}
|
||||||
{(Number(bill.amountMinor) / 100).toFixed(2)} {bill.currency}
|
</strong>
|
||||||
</span>
|
<span>{bill.createdAt.slice(0, 10)}</span>
|
||||||
</header>
|
</header>
|
||||||
<p>{bill.createdAt.slice(0, 10)}</p>
|
<div class="settings-grid">
|
||||||
|
<label class="settings-field settings-field--wide">
|
||||||
|
<span>{copy().utilityCategoryName}</span>
|
||||||
|
<input
|
||||||
|
value={utilityBillDrafts()[bill.id]?.billName ?? bill.billName}
|
||||||
|
onInput={(event) =>
|
||||||
|
setUtilityBillDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[bill.id]: {
|
||||||
|
...(current[bill.id] ?? {
|
||||||
|
billName: bill.billName,
|
||||||
|
amountMajor: minorToMajorString(BigInt(bill.amountMinor)),
|
||||||
|
currency: bill.currency
|
||||||
|
}),
|
||||||
|
billName: event.currentTarget.value
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
<span>{copy().utilityAmount}</span>
|
||||||
|
<input
|
||||||
|
value={
|
||||||
|
utilityBillDrafts()[bill.id]?.amountMajor ??
|
||||||
|
minorToMajorString(BigInt(bill.amountMinor))
|
||||||
|
}
|
||||||
|
onInput={(event) =>
|
||||||
|
setUtilityBillDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[bill.id]: {
|
||||||
|
...(current[bill.id] ?? {
|
||||||
|
billName: bill.billName,
|
||||||
|
amountMajor: minorToMajorString(BigInt(bill.amountMinor)),
|
||||||
|
currency: bill.currency
|
||||||
|
}),
|
||||||
|
amountMajor: event.currentTarget.value
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
<span>{copy().settlementCurrency}</span>
|
||||||
|
<select
|
||||||
|
value={utilityBillDrafts()[bill.id]?.currency ?? bill.currency}
|
||||||
|
onChange={(event) =>
|
||||||
|
setUtilityBillDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[bill.id]: {
|
||||||
|
...(current[bill.id] ?? {
|
||||||
|
billName: bill.billName,
|
||||||
|
amountMajor: minorToMajorString(BigInt(bill.amountMinor)),
|
||||||
|
currency: bill.currency
|
||||||
|
}),
|
||||||
|
currency: event.currentTarget.value as 'USD' | 'GEL'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="GEL">GEL</option>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="inline-actions">
|
||||||
|
<button
|
||||||
|
class="ghost-button"
|
||||||
|
type="button"
|
||||||
|
disabled={savingUtilityBillId() === bill.id}
|
||||||
|
onClick={() => void handleUpdateUtilityBill(bill.id)}
|
||||||
|
>
|
||||||
|
{savingUtilityBillId() === bill.id
|
||||||
|
? copy().savingUtilityBill
|
||||||
|
: copy().saveUtilityBillAction}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ghost-button ghost-button--danger"
|
||||||
|
type="button"
|
||||||
|
disabled={deletingUtilityBillId() === bill.id}
|
||||||
|
onClick={() => void handleDeleteUtilityBill(bill.id)}
|
||||||
|
>
|
||||||
|
{deletingUtilityBillId() === bill.id
|
||||||
|
? copy().deletingUtilityBill
|
||||||
|
: copy().deleteUtilityBillAction}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -1515,9 +1694,9 @@ function App() {
|
|||||||
<strong>{copy().utilityCategoriesTitle}</strong>
|
<strong>{copy().utilityCategoriesTitle}</strong>
|
||||||
<span>{String(adminSettings()?.categories.length ?? 0)}</span>
|
<span>{String(adminSettings()?.categories.length ?? 0)}</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="balance-list admin-sublist">
|
<div class="admin-sublist admin-sublist--plain">
|
||||||
{adminSettings()?.categories.map((category) => (
|
{adminSettings()?.categories.map((category) => (
|
||||||
<article class="ledger-item">
|
<article class="utility-bill-row">
|
||||||
<header>
|
<header>
|
||||||
<strong>{category.name}</strong>
|
<strong>{category.name}</strong>
|
||||||
<span>{category.isActive ? 'ON' : 'OFF'}</span>
|
<span>{category.isActive ? 'ON' : 'OFF'}</span>
|
||||||
@@ -1646,7 +1825,7 @@ function App() {
|
|||||||
</header>
|
</header>
|
||||||
<div class="balance-list admin-sublist">
|
<div class="balance-list admin-sublist">
|
||||||
{adminSettings()?.members.map((member) => (
|
{adminSettings()?.members.map((member) => (
|
||||||
<article class="ledger-item">
|
<article class="utility-bill-row">
|
||||||
<header>
|
<header>
|
||||||
<strong>{member.displayName}</strong>
|
<strong>{member.displayName}</strong>
|
||||||
<span>{member.isAdmin ? copy().adminTag : copy().residentTag}</span>
|
<span>{member.isAdmin ? copy().adminTag : copy().residentTag}</span>
|
||||||
@@ -1709,7 +1888,7 @@ function App() {
|
|||||||
{pendingMembers().length === 0 ? (
|
{pendingMembers().length === 0 ? (
|
||||||
<p>{copy().pendingMembersEmpty}</p>
|
<p>{copy().pendingMembersEmpty}</p>
|
||||||
) : (
|
) : (
|
||||||
<div class="balance-list admin-sublist">
|
<div class="admin-sublist admin-sublist--plain">
|
||||||
{pendingMembers().map((member) => (
|
{pendingMembers().map((member) => (
|
||||||
<article class="ledger-item">
|
<article class="ledger-item">
|
||||||
<header>
|
<header>
|
||||||
@@ -1751,7 +1930,7 @@ function App() {
|
|||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div class="home-grid">
|
<div class="home-grid home-grid--summary">
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<span>{copy().totalDue}</span>
|
<span>{copy().totalDue}</span>
|
||||||
<strong>
|
<strong>
|
||||||
@@ -1852,7 +2031,7 @@ function App() {
|
|||||||
</article>
|
</article>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<article class="balance-item">
|
<article class="balance-item balance-item--wide">
|
||||||
<header>
|
<header>
|
||||||
<strong>{copy().latestActivityTitle}</strong>
|
<strong>{copy().latestActivityTitle}</strong>
|
||||||
</header>
|
</header>
|
||||||
@@ -1863,9 +2042,9 @@ function App() {
|
|||||||
data.ledger.length === 0 ? (
|
data.ledger.length === 0 ? (
|
||||||
<p>{copy().latestActivityEmpty}</p>
|
<p>{copy().latestActivityEmpty}</p>
|
||||||
) : (
|
) : (
|
||||||
<div class="ledger-list">
|
<div class="activity-list">
|
||||||
{data.ledger.slice(0, 3).map((entry) => (
|
{data.ledger.slice(0, 3).map((entry) => (
|
||||||
<article class="ledger-item">
|
<article class="activity-row">
|
||||||
<header>
|
<header>
|
||||||
<strong>{ledgerTitle(entry)}</strong>
|
<strong>{ledgerTitle(entry)}</strong>
|
||||||
<span>{ledgerPrimaryAmount(entry)}</span>
|
<span>{ledgerPrimaryAmount(entry)}</span>
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ export const dictionary = {
|
|||||||
utilityAmount: 'Utility amount',
|
utilityAmount: 'Utility amount',
|
||||||
addUtilityBillAction: 'Add utility bill',
|
addUtilityBillAction: 'Add utility bill',
|
||||||
savingUtilityBill: 'Saving utility bill…',
|
savingUtilityBill: 'Saving utility bill…',
|
||||||
|
saveUtilityBillAction: 'Save utility bill',
|
||||||
|
deleteUtilityBillAction: 'Delete utility bill',
|
||||||
|
deletingUtilityBill: 'Deleting utility bill…',
|
||||||
utilityBillsEmpty: 'No utility bills recorded for this cycle yet.',
|
utilityBillsEmpty: 'No utility bills recorded for this cycle yet.',
|
||||||
rentAmount: 'Rent amount',
|
rentAmount: 'Rent amount',
|
||||||
rentDueDay: 'Rent due day',
|
rentDueDay: 'Rent due day',
|
||||||
@@ -229,6 +232,9 @@ export const dictionary = {
|
|||||||
utilityAmount: 'Сумма коммуналки',
|
utilityAmount: 'Сумма коммуналки',
|
||||||
addUtilityBillAction: 'Добавить коммунальный счёт',
|
addUtilityBillAction: 'Добавить коммунальный счёт',
|
||||||
savingUtilityBill: 'Сохраняем счёт…',
|
savingUtilityBill: 'Сохраняем счёт…',
|
||||||
|
saveUtilityBillAction: 'Сохранить счёт',
|
||||||
|
deleteUtilityBillAction: 'Удалить счёт',
|
||||||
|
deletingUtilityBill: 'Удаляем счёт…',
|
||||||
utilityBillsEmpty: 'Для этого цикла пока нет коммунальных счетов.',
|
utilityBillsEmpty: 'Для этого цикла пока нет коммунальных счетов.',
|
||||||
rentAmount: 'Сумма аренды',
|
rentAmount: 'Сумма аренды',
|
||||||
rentDueDay: 'День оплаты аренды',
|
rentDueDay: 'День оплаты аренды',
|
||||||
|
|||||||
@@ -231,14 +231,17 @@ button {
|
|||||||
.ledger-list,
|
.ledger-list,
|
||||||
.home-grid,
|
.home-grid,
|
||||||
.admin-layout,
|
.admin-layout,
|
||||||
.admin-sublist {
|
.admin-sublist,
|
||||||
|
.activity-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.balance-item,
|
.balance-item,
|
||||||
.ledger-item,
|
.ledger-item,
|
||||||
.stat-card {
|
.stat-card,
|
||||||
|
.activity-row,
|
||||||
|
.utility-bill-row {
|
||||||
border: 1px solid rgb(255 255 255 / 0.08);
|
border: 1px solid rgb(255 255 255 / 0.08);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
@@ -253,7 +256,9 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.balance-item header,
|
.balance-item header,
|
||||||
.ledger-item header {
|
.ledger-item header,
|
||||||
|
.activity-row header,
|
||||||
|
.utility-bill-row header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
@@ -262,13 +267,17 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.balance-item strong,
|
.balance-item strong,
|
||||||
.ledger-item strong {
|
.ledger-item strong,
|
||||||
|
.activity-row strong,
|
||||||
|
.utility-bill-row strong {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.balance-item p,
|
.balance-item p,
|
||||||
.ledger-item p {
|
.ledger-item p,
|
||||||
|
.activity-row p,
|
||||||
|
.utility-bill-row p {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +294,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-grid {
|
.home-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
@@ -360,6 +369,10 @@ button {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sublist--plain {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-card--wide {
|
.admin-card--wide {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -379,6 +392,19 @@ button {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid rgb(255 255 255 / 0.12);
|
border: 1px solid rgb(255 255 255 / 0.12);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgb(255 255 255 / 0.04);
|
||||||
|
color: inherit;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field__value {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 48px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.12);
|
||||||
|
border-radius: 14px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
background: rgb(255 255 255 / 0.04);
|
background: rgb(255 255 255 / 0.04);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -399,10 +425,33 @@ button {
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ghost-button--danger {
|
||||||
|
border-color: rgb(247 115 115 / 0.28);
|
||||||
|
color: #ffc5c5;
|
||||||
|
}
|
||||||
|
|
||||||
.panel--wide {
|
.panel--wide {
|
||||||
min-height: 170px;
|
min-height: 170px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-row,
|
||||||
|
.utility-bill-row {
|
||||||
|
background: rgb(255 255 255 / 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-row header,
|
||||||
|
.utility-bill-row header {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-row strong {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-item--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 760px) {
|
@media (min-width: 760px) {
|
||||||
.shell {
|
.shell {
|
||||||
max-width: 920px;
|
max-width: 920px;
|
||||||
@@ -418,6 +467,10 @@ button {
|
|||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-grid--summary {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.balance-breakdown {
|
.balance-breakdown {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -441,6 +494,10 @@ button {
|
|||||||
.admin-card--wide {
|
.admin-card--wide {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.balance-item--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 980px) {
|
@media (min-width: 980px) {
|
||||||
@@ -448,3 +505,37 @@ button {
|
|||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 759px) {
|
||||||
|
.shell {
|
||||||
|
padding: 18px 14px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switch {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-breakdown {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section__header {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-row header,
|
||||||
|
.ledger-item header,
|
||||||
|
.utility-bill-row header,
|
||||||
|
.balance-item header {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -668,3 +668,66 @@ export async function addMiniAppUtilityBill(
|
|||||||
|
|
||||||
return payload.cycleState
|
return payload.cycleState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateMiniAppUtilityBill(
|
||||||
|
initData: string,
|
||||||
|
input: {
|
||||||
|
billId: string
|
||||||
|
billName: string
|
||||||
|
amountMajor: string
|
||||||
|
currency: 'USD' | 'GEL'
|
||||||
|
}
|
||||||
|
): Promise<MiniAppAdminCycleState> {
|
||||||
|
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/utility-bills/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData,
|
||||||
|
...input
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
ok: boolean
|
||||||
|
authorized?: boolean
|
||||||
|
cycleState?: MiniAppAdminCycleState
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !payload.authorized || !payload.cycleState) {
|
||||||
|
throw new Error(payload.error ?? 'Failed to update utility bill')
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.cycleState
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMiniAppUtilityBill(
|
||||||
|
initData: string,
|
||||||
|
billId: string
|
||||||
|
): Promise<MiniAppAdminCycleState> {
|
||||||
|
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/utility-bills/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData,
|
||||||
|
billId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
ok: boolean
|
||||||
|
authorized?: boolean
|
||||||
|
cycleState?: MiniAppAdminCycleState
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !payload.authorized || !payload.cycleState) {
|
||||||
|
throw new Error(payload.error ?? 'Failed to delete utility bill')
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.cycleState
|
||||||
|
}
|
||||||
|
|||||||
@@ -296,6 +296,54 @@ export function createDbFinanceRepository(
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateUtilityBill(input) {
|
||||||
|
const rows = await db
|
||||||
|
.update(schema.utilityBills)
|
||||||
|
.set({
|
||||||
|
billName: input.billName,
|
||||||
|
amountMinor: input.amountMinor,
|
||||||
|
currency: input.currency
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.utilityBills.householdId, householdId),
|
||||||
|
eq(schema.utilityBills.id, input.billId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({
|
||||||
|
id: schema.utilityBills.id,
|
||||||
|
billName: schema.utilityBills.billName,
|
||||||
|
amountMinor: schema.utilityBills.amountMinor,
|
||||||
|
currency: schema.utilityBills.currency,
|
||||||
|
createdByMemberId: schema.utilityBills.createdByMemberId,
|
||||||
|
createdAt: schema.utilityBills.createdAt
|
||||||
|
})
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
if (!row) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
currency: toCurrencyCode(row.currency),
|
||||||
|
createdAt: instantFromDatabaseValue(row.createdAt)!
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteUtilityBill(billId) {
|
||||||
|
const rows = await db
|
||||||
|
.delete(schema.utilityBills)
|
||||||
|
.where(
|
||||||
|
and(eq(schema.utilityBills.householdId, householdId), eq(schema.utilityBills.id, billId))
|
||||||
|
)
|
||||||
|
.returning({
|
||||||
|
id: schema.utilityBills.id
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows.length > 0
|
||||||
|
},
|
||||||
|
|
||||||
async getRentRuleForPeriod(period) {
|
async getRentRuleForPeriod(period) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -121,6 +121,14 @@ class FinanceRepositoryStub implements FinanceRepository {
|
|||||||
this.lastUtilityBill = input
|
this.lastUtilityBill = input
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateUtilityBill() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUtilityBill() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
async getRentRuleForPeriod(): Promise<FinanceRentRuleRecord | null> {
|
async getRentRuleForPeriod(): Promise<FinanceRentRuleRecord | null> {
|
||||||
return this.rentRule
|
return this.rentRule
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -466,6 +466,17 @@ export interface FinanceCommandService {
|
|||||||
currency: CurrencyCode
|
currency: CurrencyCode
|
||||||
period: string
|
period: string
|
||||||
} | null>
|
} | null>
|
||||||
|
updateUtilityBill(
|
||||||
|
billId: string,
|
||||||
|
billName: string,
|
||||||
|
amountArg: string,
|
||||||
|
currencyArg?: string
|
||||||
|
): Promise<{
|
||||||
|
billId: string
|
||||||
|
amount: Money
|
||||||
|
currency: CurrencyCode
|
||||||
|
} | null>
|
||||||
|
deleteUtilityBill(billId: string): Promise<boolean>
|
||||||
generateDashboard(periodArg?: string): Promise<FinanceDashboard | null>
|
generateDashboard(periodArg?: string): Promise<FinanceDashboard | null>
|
||||||
generateStatement(periodArg?: string): Promise<string | null>
|
generateStatement(periodArg?: string): Promise<string | null>
|
||||||
}
|
}
|
||||||
@@ -596,6 +607,34 @@ export function createFinanceCommandService(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateUtilityBill(billId, billName, amountArg, currencyArg) {
|
||||||
|
const settings = await householdConfigurationRepository.getHouseholdBillingSettings(
|
||||||
|
dependencies.householdId
|
||||||
|
)
|
||||||
|
const currency = parseCurrency(currencyArg, settings.settlementCurrency)
|
||||||
|
const amount = Money.fromMajor(amountArg, currency)
|
||||||
|
const updated = await repository.updateUtilityBill({
|
||||||
|
billId,
|
||||||
|
billName,
|
||||||
|
amountMinor: amount.amountMinor,
|
||||||
|
currency
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
billId: updated.id,
|
||||||
|
amount,
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUtilityBill(billId) {
|
||||||
|
return repository.deleteUtilityBill(billId)
|
||||||
|
},
|
||||||
|
|
||||||
async generateStatement(periodArg) {
|
async generateStatement(periodArg) {
|
||||||
const dashboard = await buildFinanceDashboard(dependencies, periodArg)
|
const dashboard = await buildFinanceDashboard(dependencies, periodArg)
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
|
|||||||
@@ -165,6 +165,13 @@ export interface FinanceRepository {
|
|||||||
currency: CurrencyCode
|
currency: CurrencyCode
|
||||||
createdByMemberId: string
|
createdByMemberId: string
|
||||||
}): Promise<void>
|
}): Promise<void>
|
||||||
|
updateUtilityBill(input: {
|
||||||
|
billId: string
|
||||||
|
billName: string
|
||||||
|
amountMinor: bigint
|
||||||
|
currency: CurrencyCode
|
||||||
|
}): Promise<FinanceUtilityBillRecord | null>
|
||||||
|
deleteUtilityBill(billId: string): Promise<boolean>
|
||||||
getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null>
|
getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null>
|
||||||
getUtilityTotalForCycle(cycleId: string): Promise<bigint>
|
getUtilityTotalForCycle(cycleId: string): Promise<bigint>
|
||||||
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
|
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
|
||||||
|
|||||||
Reference in New Issue
Block a user