mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 11:54:03 +00:00
feat(miniapp): improve mobile billing and utility controls
This commit is contained in:
@@ -49,8 +49,10 @@ import {
|
||||
createMiniAppAddUtilityBillHandler,
|
||||
createMiniAppBillingCycleHandler,
|
||||
createMiniAppCloseCycleHandler,
|
||||
createMiniAppDeleteUtilityBillHandler,
|
||||
createMiniAppOpenCycleHandler,
|
||||
createMiniAppRentUpdateHandler
|
||||
createMiniAppRentUpdateHandler,
|
||||
createMiniAppUpdateUtilityBillHandler
|
||||
} from './miniapp-billing'
|
||||
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
|
||||
import { createNbgExchangeRateProvider } from './nbg-exchange-rates'
|
||||
@@ -463,6 +465,24 @@ const server = createBotWebhookServer({
|
||||
logger: getLogger('miniapp-billing')
|
||||
})
|
||||
: 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
|
||||
? createMiniAppLocalePreferenceHandler({
|
||||
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||
|
||||
@@ -449,6 +449,11 @@ export function createMiniAppUpdateSettingsHandler(options: {
|
||||
const result = await options.miniAppAdminService.updateSettings({
|
||||
householdId: session.member.householdId,
|
||||
actorIsAdmin: session.member.isAdmin,
|
||||
...(payload.settlementCurrency
|
||||
? {
|
||||
settlementCurrency: payload.settlementCurrency
|
||||
}
|
||||
: {}),
|
||||
...(payload.rentAmountMajor !== undefined
|
||||
? {
|
||||
rentAmountMajor: payload.rentAmountMajor
|
||||
|
||||
@@ -11,8 +11,10 @@ import type {
|
||||
import {
|
||||
createMiniAppAddUtilityBillHandler,
|
||||
createMiniAppBillingCycleHandler,
|
||||
createMiniAppDeleteUtilityBillHandler,
|
||||
createMiniAppOpenCycleHandler,
|
||||
createMiniAppRentUpdateHandler
|
||||
createMiniAppRentUpdateHandler,
|
||||
createMiniAppUpdateUtilityBillHandler
|
||||
} from './miniapp-billing'
|
||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||
|
||||
@@ -179,6 +181,12 @@ function createFinanceServiceStub(): FinanceCommandService {
|
||||
currency: 'USD',
|
||||
period: '2026-03'
|
||||
}),
|
||||
updateUtilityBill: async () => ({
|
||||
billId: 'utility-1',
|
||||
amount: Money.fromMinor(4500n, 'USD'),
|
||||
currency: 'USD'
|
||||
}),
|
||||
deleteUtilityBill: async () => true,
|
||||
generateDashboard: 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', () => {
|
||||
test('opens a billing cycle for an authenticated admin', async () => {
|
||||
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: {
|
||||
allowedOrigins: readonly 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,
|
||||
saveCycleExchangeRate: async (input) => input,
|
||||
addUtilityBill: async () => {},
|
||||
updateUtilityBill: async () => null,
|
||||
deleteUtilityBill: async () => false,
|
||||
getRentRuleForPeriod: async () => ({
|
||||
amountMinor: 70000n,
|
||||
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: {
|
||||
handler: async () =>
|
||||
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 () => {
|
||||
const response = await server.fetch(
|
||||
new Request('http://localhost/api/miniapp/admin/approve-member', {
|
||||
|
||||
@@ -92,6 +92,18 @@ export interface BotWebhookServerOptions {
|
||||
handler: (request: Request) => Promise<Response>
|
||||
}
|
||||
| undefined
|
||||
miniAppUpdateUtilityBill?:
|
||||
| {
|
||||
path?: string
|
||||
handler: (request: Request) => Promise<Response>
|
||||
}
|
||||
| undefined
|
||||
miniAppDeleteUtilityBill?:
|
||||
| {
|
||||
path?: string
|
||||
handler: (request: Request) => Promise<Response>
|
||||
}
|
||||
| undefined
|
||||
miniAppLocalePreference?:
|
||||
| {
|
||||
path?: string
|
||||
@@ -153,6 +165,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
||||
const miniAppRentUpdatePath = options.miniAppRentUpdate?.path ?? '/api/miniapp/admin/rent/update'
|
||||
const miniAppAddUtilityBillPath =
|
||||
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 =
|
||||
options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale'
|
||||
const schedulerPathPrefix = options.scheduler
|
||||
@@ -233,6 +249,14 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
||||
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) {
|
||||
return await options.miniAppLocalePreference.handler(request)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
addMiniAppUtilityBill,
|
||||
approveMiniAppPendingMember,
|
||||
closeMiniAppBillingCycle,
|
||||
deleteMiniAppUtilityBill,
|
||||
fetchMiniAppAdminSettings,
|
||||
fetchMiniAppBillingCycle,
|
||||
fetchMiniAppDashboard,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
updateMiniAppBillingSettings,
|
||||
updateMiniAppCycleRent,
|
||||
upsertMiniAppUtilityCategory,
|
||||
updateMiniAppUtilityBill,
|
||||
type MiniAppDashboard,
|
||||
type MiniAppPendingMember
|
||||
} from './miniapp-api'
|
||||
@@ -62,6 +64,12 @@ type SessionState =
|
||||
|
||||
type NavigationKey = 'home' | 'balances' | 'ledger' | 'house'
|
||||
|
||||
type UtilityBillDraft = {
|
||||
billName: string
|
||||
amountMajor: string
|
||||
currency: 'USD' | 'GEL'
|
||||
}
|
||||
|
||||
const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
||||
status: 'ready',
|
||||
mode: 'demo',
|
||||
@@ -186,6 +194,21 @@ function ledgerSecondaryAmount(entry: MiniAppDashboard['ledger'][number]): strin
|
||||
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() {
|
||||
const [locale, setLocale] = createSignal<Locale>('en')
|
||||
const [session, setSession] = createSignal<SessionState>({
|
||||
@@ -209,6 +232,11 @@ function App() {
|
||||
const [closingCycle, setClosingCycle] = createSignal(false)
|
||||
const [savingCycleRent, setSavingCycleRent] = 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({
|
||||
settlementCurrency: 'GEL' as 'USD' | 'GEL',
|
||||
rentAmountMajor: '',
|
||||
@@ -222,7 +250,8 @@ function App() {
|
||||
const [newCategoryName, setNewCategoryName] = createSignal('')
|
||||
const [cycleForm, setCycleForm] = createSignal({
|
||||
period: defaultCyclePeriod(),
|
||||
currency: 'GEL' as 'USD' | 'GEL',
|
||||
rentCurrency: 'USD' as 'USD' | 'GEL',
|
||||
utilityCurrency: 'GEL' as 'USD' | 'GEL',
|
||||
rentAmountMajor: '',
|
||||
utilityCategorySlug: '',
|
||||
utilityAmountMajor: ''
|
||||
@@ -320,7 +349,8 @@ function App() {
|
||||
)
|
||||
setCycleForm((current) => ({
|
||||
...current,
|
||||
currency: current.currency || payload.settings.settlementCurrency,
|
||||
rentCurrency: payload.settings.rentCurrency,
|
||||
utilityCurrency: payload.settings.settlementCurrency,
|
||||
utilityCategorySlug:
|
||||
current.utilityCategorySlug ||
|
||||
payload.categories.find((category) => category.isActive)?.slug ||
|
||||
@@ -351,13 +381,15 @@ function App() {
|
||||
try {
|
||||
const payload = await fetchMiniAppBillingCycle(initData)
|
||||
setCycleState(payload)
|
||||
setUtilityBillDrafts(cycleUtilityBillDrafts(payload.utilityBills))
|
||||
setCycleForm((current) => ({
|
||||
...current,
|
||||
period: payload.cycle?.period ?? current.period,
|
||||
currency:
|
||||
payload.cycle?.currency ??
|
||||
adminSettings()?.settings.settlementCurrency ??
|
||||
current.currency,
|
||||
rentCurrency:
|
||||
payload.rentRule?.currency ??
|
||||
adminSettings()?.settings.rentCurrency ??
|
||||
current.rentCurrency,
|
||||
utilityCurrency: adminSettings()?.settings.settlementCurrency ?? current.utilityCurrency,
|
||||
rentAmountMajor: payload.rentRule
|
||||
? (Number(payload.rentRule.amountMinor) / 100).toFixed(2)
|
||||
: '',
|
||||
@@ -707,7 +739,8 @@ function App() {
|
||||
)
|
||||
setCycleForm((current) => ({
|
||||
...current,
|
||||
currency: cycleState()?.cycle?.currency ?? settings.settlementCurrency
|
||||
rentCurrency: settings.rentCurrency,
|
||||
utilityCurrency: settings.settlementCurrency
|
||||
}))
|
||||
} finally {
|
||||
setSavingBillingSettings(false)
|
||||
@@ -726,13 +759,14 @@ function App() {
|
||||
try {
|
||||
const state = await openMiniAppBillingCycle(initData, {
|
||||
period: cycleForm().period,
|
||||
currency: cycleForm().currency
|
||||
currency: billingForm().settlementCurrency
|
||||
})
|
||||
setCycleState(state)
|
||||
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
||||
setCycleForm((current) => ({
|
||||
...current,
|
||||
period: state.cycle?.period ?? current.period,
|
||||
currency: state.cycle?.currency ?? current.currency
|
||||
utilityCurrency: billingForm().settlementCurrency
|
||||
}))
|
||||
} finally {
|
||||
setOpeningCycle(false)
|
||||
@@ -751,6 +785,7 @@ function App() {
|
||||
try {
|
||||
const state = await closeMiniAppBillingCycle(initData, cycleState()?.cycle?.period)
|
||||
setCycleState(state)
|
||||
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
||||
} finally {
|
||||
setClosingCycle(false)
|
||||
}
|
||||
@@ -768,7 +803,7 @@ function App() {
|
||||
try {
|
||||
const state = await updateMiniAppCycleRent(initData, {
|
||||
amountMajor: cycleForm().rentAmountMajor,
|
||||
currency: cycleForm().currency,
|
||||
currency: cycleForm().rentCurrency,
|
||||
...(cycleState()?.cycle?.period
|
||||
? {
|
||||
period: cycleState()!.cycle!.period
|
||||
@@ -776,6 +811,7 @@ function App() {
|
||||
: {})
|
||||
})
|
||||
setCycleState(state)
|
||||
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
||||
} finally {
|
||||
setSavingCycleRent(false)
|
||||
}
|
||||
@@ -803,9 +839,10 @@ function App() {
|
||||
const state = await addMiniAppUtilityBill(initData, {
|
||||
billName: selectedCategory.name,
|
||||
amountMajor: cycleForm().utilityAmountMajor,
|
||||
currency: cycleForm().currency
|
||||
currency: cycleForm().utilityCurrency
|
||||
})
|
||||
setCycleState(state)
|
||||
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
||||
setCycleForm((current) => ({
|
||||
...current,
|
||||
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: {
|
||||
slug?: string
|
||||
name: string
|
||||
@@ -1138,7 +1225,7 @@ function App() {
|
||||
<p>
|
||||
{copy().billingCycleStatus.replace(
|
||||
'{currency}',
|
||||
cycleState()?.cycle?.currency ?? cycleForm().currency
|
||||
cycleState()?.cycle?.currency ?? billingForm().settlementCurrency
|
||||
)}
|
||||
</p>
|
||||
<Show when={dashboard()}>
|
||||
@@ -1168,11 +1255,11 @@ function App() {
|
||||
<label class="settings-field">
|
||||
<span>{copy().shareRent}</span>
|
||||
<select
|
||||
value={cycleForm().currency}
|
||||
value={cycleForm().rentCurrency}
|
||||
onChange={(event) =>
|
||||
setCycleForm((current) => ({
|
||||
...current,
|
||||
currency: event.currentTarget.value as 'USD' | 'GEL'
|
||||
rentCurrency: event.currentTarget.value as 'USD' | 'GEL'
|
||||
}))
|
||||
}
|
||||
>
|
||||
@@ -1218,21 +1305,12 @@ function App() {
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label class="settings-field">
|
||||
<div class="settings-field">
|
||||
<span>{copy().settlementCurrency}</span>
|
||||
<select
|
||||
value={cycleForm().currency}
|
||||
onChange={(event) =>
|
||||
setCycleForm((current) => ({
|
||||
...current,
|
||||
currency: event.currentTarget.value as 'USD' | 'GEL'
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="GEL">GEL</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="settings-field__value">
|
||||
{billingForm().settlementCurrency}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="ghost-button"
|
||||
@@ -1447,7 +1525,7 @@ function App() {
|
||||
<article class="balance-item">
|
||||
<header>
|
||||
<strong>{copy().utilityLedgerTitle}</strong>
|
||||
<span>{cycleState()?.cycle?.currency ?? billingForm().settlementCurrency}</span>
|
||||
<span>{cycleForm().utilityCurrency}</span>
|
||||
</header>
|
||||
<div class="settings-grid">
|
||||
<label class="settings-field">
|
||||
@@ -1480,6 +1558,21 @@ function App() {
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
<button
|
||||
class="ghost-button"
|
||||
@@ -1491,17 +1584,103 @@ function App() {
|
||||
>
|
||||
{savingUtilityBill() ? copy().savingUtilityBill : copy().addUtilityBillAction}
|
||||
</button>
|
||||
<div class="balance-list admin-sublist">
|
||||
<div class="admin-sublist admin-sublist--plain">
|
||||
{cycleState()?.utilityBills.length ? (
|
||||
cycleState()?.utilityBills.map((bill) => (
|
||||
<article class="ledger-item">
|
||||
<article class="utility-bill-row">
|
||||
<header>
|
||||
<strong>{bill.billName}</strong>
|
||||
<span>
|
||||
{(Number(bill.amountMinor) / 100).toFixed(2)} {bill.currency}
|
||||
</span>
|
||||
<strong>
|
||||
{utilityBillDrafts()[bill.id]?.billName ?? bill.billName}
|
||||
</strong>
|
||||
<span>{bill.createdAt.slice(0, 10)}</span>
|
||||
</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>
|
||||
))
|
||||
) : (
|
||||
@@ -1515,9 +1694,9 @@ function App() {
|
||||
<strong>{copy().utilityCategoriesTitle}</strong>
|
||||
<span>{String(adminSettings()?.categories.length ?? 0)}</span>
|
||||
</header>
|
||||
<div class="balance-list admin-sublist">
|
||||
<div class="admin-sublist admin-sublist--plain">
|
||||
{adminSettings()?.categories.map((category) => (
|
||||
<article class="ledger-item">
|
||||
<article class="utility-bill-row">
|
||||
<header>
|
||||
<strong>{category.name}</strong>
|
||||
<span>{category.isActive ? 'ON' : 'OFF'}</span>
|
||||
@@ -1646,7 +1825,7 @@ function App() {
|
||||
</header>
|
||||
<div class="balance-list admin-sublist">
|
||||
{adminSettings()?.members.map((member) => (
|
||||
<article class="ledger-item">
|
||||
<article class="utility-bill-row">
|
||||
<header>
|
||||
<strong>{member.displayName}</strong>
|
||||
<span>{member.isAdmin ? copy().adminTag : copy().residentTag}</span>
|
||||
@@ -1709,7 +1888,7 @@ function App() {
|
||||
{pendingMembers().length === 0 ? (
|
||||
<p>{copy().pendingMembersEmpty}</p>
|
||||
) : (
|
||||
<div class="balance-list admin-sublist">
|
||||
<div class="admin-sublist admin-sublist--plain">
|
||||
{pendingMembers().map((member) => (
|
||||
<article class="ledger-item">
|
||||
<header>
|
||||
@@ -1751,7 +1930,7 @@ function App() {
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<div class="home-grid">
|
||||
<div class="home-grid home-grid--summary">
|
||||
<article class="stat-card">
|
||||
<span>{copy().totalDue}</span>
|
||||
<strong>
|
||||
@@ -1852,7 +2031,7 @@ function App() {
|
||||
</article>
|
||||
)}
|
||||
|
||||
<article class="balance-item">
|
||||
<article class="balance-item balance-item--wide">
|
||||
<header>
|
||||
<strong>{copy().latestActivityTitle}</strong>
|
||||
</header>
|
||||
@@ -1863,9 +2042,9 @@ function App() {
|
||||
data.ledger.length === 0 ? (
|
||||
<p>{copy().latestActivityEmpty}</p>
|
||||
) : (
|
||||
<div class="ledger-list">
|
||||
<div class="activity-list">
|
||||
{data.ledger.slice(0, 3).map((entry) => (
|
||||
<article class="ledger-item">
|
||||
<article class="activity-row">
|
||||
<header>
|
||||
<strong>{ledgerTitle(entry)}</strong>
|
||||
<span>{ledgerPrimaryAmount(entry)}</span>
|
||||
|
||||
@@ -97,6 +97,9 @@ export const dictionary = {
|
||||
utilityAmount: 'Utility amount',
|
||||
addUtilityBillAction: 'Add 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.',
|
||||
rentAmount: 'Rent amount',
|
||||
rentDueDay: 'Rent due day',
|
||||
@@ -229,6 +232,9 @@ export const dictionary = {
|
||||
utilityAmount: 'Сумма коммуналки',
|
||||
addUtilityBillAction: 'Добавить коммунальный счёт',
|
||||
savingUtilityBill: 'Сохраняем счёт…',
|
||||
saveUtilityBillAction: 'Сохранить счёт',
|
||||
deleteUtilityBillAction: 'Удалить счёт',
|
||||
deletingUtilityBill: 'Удаляем счёт…',
|
||||
utilityBillsEmpty: 'Для этого цикла пока нет коммунальных счетов.',
|
||||
rentAmount: 'Сумма аренды',
|
||||
rentDueDay: 'День оплаты аренды',
|
||||
|
||||
@@ -231,14 +231,17 @@ button {
|
||||
.ledger-list,
|
||||
.home-grid,
|
||||
.admin-layout,
|
||||
.admin-sublist {
|
||||
.admin-sublist,
|
||||
.activity-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.balance-item,
|
||||
.ledger-item,
|
||||
.stat-card {
|
||||
.stat-card,
|
||||
.activity-row,
|
||||
.utility-bill-row {
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
@@ -253,7 +256,9 @@ button {
|
||||
}
|
||||
|
||||
.balance-item header,
|
||||
.ledger-item header {
|
||||
.ledger-item header,
|
||||
.activity-row header,
|
||||
.utility-bill-row header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
@@ -262,13 +267,17 @@ button {
|
||||
}
|
||||
|
||||
.balance-item strong,
|
||||
.ledger-item strong {
|
||||
.ledger-item strong,
|
||||
.activity-row strong,
|
||||
.utility-bill-row strong {
|
||||
font-size: 1rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.balance-item p,
|
||||
.ledger-item p {
|
||||
.ledger-item p,
|
||||
.activity-row p,
|
||||
.utility-bill-row p {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
@@ -285,7 +294,7 @@ button {
|
||||
}
|
||||
|
||||
.home-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@@ -360,6 +369,10 @@ button {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.admin-sublist--plain {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-card--wide {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -379,6 +392,19 @@ button {
|
||||
width: 100%;
|
||||
border: 1px solid rgb(255 255 255 / 0.12);
|
||||
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;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
color: inherit;
|
||||
@@ -399,10 +425,33 @@ button {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.ghost-button--danger {
|
||||
border-color: rgb(247 115 115 / 0.28);
|
||||
color: #ffc5c5;
|
||||
}
|
||||
|
||||
.panel--wide {
|
||||
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) {
|
||||
.shell {
|
||||
max-width: 920px;
|
||||
@@ -418,6 +467,10 @@ button {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.home-grid--summary {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.balance-breakdown {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
@@ -441,6 +494,10 @@ button {
|
||||
.admin-card--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.balance-item--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 980px) {
|
||||
@@ -448,3 +505,37 @@ button {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
const rows = await db
|
||||
.select({
|
||||
|
||||
@@ -121,6 +121,14 @@ class FinanceRepositoryStub implements FinanceRepository {
|
||||
this.lastUtilityBill = input
|
||||
}
|
||||
|
||||
async updateUtilityBill() {
|
||||
return null
|
||||
}
|
||||
|
||||
async deleteUtilityBill() {
|
||||
return false
|
||||
}
|
||||
|
||||
async getRentRuleForPeriod(): Promise<FinanceRentRuleRecord | null> {
|
||||
return this.rentRule
|
||||
}
|
||||
|
||||
@@ -466,6 +466,17 @@ export interface FinanceCommandService {
|
||||
currency: CurrencyCode
|
||||
period: string
|
||||
} | 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>
|
||||
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) {
|
||||
const dashboard = await buildFinanceDashboard(dependencies, periodArg)
|
||||
if (!dashboard) {
|
||||
|
||||
@@ -165,6 +165,13 @@ export interface FinanceRepository {
|
||||
currency: CurrencyCode
|
||||
createdByMemberId: string
|
||||
}): 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>
|
||||
getUtilityTotalForCycle(cycleId: string): Promise<bigint>
|
||||
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
|
||||
|
||||
Reference in New Issue
Block a user