mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(WHE-20): implement deterministic monthly settlement engine
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -31,6 +31,9 @@
|
|||||||
},
|
},
|
||||||
"packages/application": {
|
"packages/application": {
|
||||||
"name": "@household/application",
|
"name": "@household/application",
|
||||||
|
"dependencies": {
|
||||||
|
"@household/domain": "workspace:*",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"packages/config": {
|
"packages/config": {
|
||||||
"name": "@household/config",
|
"name": "@household/config",
|
||||||
|
|||||||
80
docs/specs/HOUSEBOT-011-monthly-settlement-engine.md
Normal file
80
docs/specs/HOUSEBOT-011-monthly-settlement-engine.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# HOUSEBOT-011: Monthly Settlement Engine
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Implement a deterministic monthly settlement use-case that computes per-member dues from rent, utilities, and shared purchases.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Compute per-member net due amounts deterministically.
|
||||||
|
- Support utility split modes: equal and weighted-by-days.
|
||||||
|
- Model purchase offsets where payer reimbursement reduces their due.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Persistence adapters.
|
||||||
|
- Telegram command handlers.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- In: pure settlement use-case logic and tests.
|
||||||
|
- Out: DB writes, HTTP/bot integration.
|
||||||
|
|
||||||
|
## Interfaces and Contracts
|
||||||
|
|
||||||
|
- Input: `SettlementInput` from `@household/domain`.
|
||||||
|
- Output: `SettlementResult` with per-member line breakdown.
|
||||||
|
- Entry point: `calculateMonthlySettlement(input)` in application package.
|
||||||
|
|
||||||
|
## Domain Rules
|
||||||
|
|
||||||
|
- Use integer minor units only via `Money` value object.
|
||||||
|
- Rent is split evenly among active members.
|
||||||
|
- Utility split:
|
||||||
|
- `equal`: evenly among active members.
|
||||||
|
- `weighted_by_days`: proportional to `utilityDays` for each active member.
|
||||||
|
- Purchase offset per member:
|
||||||
|
- `purchaseSharedCost - purchasePaid`
|
||||||
|
- Net due:
|
||||||
|
- `rentShare + utilityShare + purchaseOffset`
|
||||||
|
|
||||||
|
## Data Model Changes
|
||||||
|
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Security and Privacy
|
||||||
|
|
||||||
|
- No PII required in settlement computation.
|
||||||
|
- Uses internal typed member IDs only.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
- Explanations list on each line provides deterministic calculation fragments.
|
||||||
|
|
||||||
|
## Edge Cases and Failure Modes
|
||||||
|
|
||||||
|
- No active members.
|
||||||
|
- Weighted utility split with invalid or missing day values.
|
||||||
|
- Purchase payer not in active members.
|
||||||
|
- Negative monetary inputs.
|
||||||
|
- Currency mismatch across input values.
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- Unit:
|
||||||
|
- 3-member equal split with purchase offset.
|
||||||
|
- 4-member weighted utility split.
|
||||||
|
- 5-member deterministic fixture with multiple purchases.
|
||||||
|
- Invalid weighted utility day input.
|
||||||
|
- Invalid purchase payer.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Equal utility split default implemented.
|
||||||
|
- [ ] Day-based utility split implemented.
|
||||||
|
- [ ] Deterministic fixtures for 3-5 roommate scenarios.
|
||||||
|
- [ ] Explicit errors for invalid settlement inputs.
|
||||||
|
|
||||||
|
## Rollout Plan
|
||||||
|
|
||||||
|
- Merge as dependency for bot command handlers and statement generation flow.
|
||||||
@@ -2,10 +2,16 @@
|
|||||||
"name": "@household/application",
|
"name": "@household/application",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun build src/index.ts --outdir dist --target bun",
|
"build": "bun build src/index.ts --outdir dist --target bun",
|
||||||
"typecheck": "tsgo --project tsconfig.json --noEmit",
|
"typecheck": "tsgo --project tsconfig.json --noEmit",
|
||||||
"test": "bun test --pass-with-no-tests",
|
"test": "bun test --pass-with-no-tests",
|
||||||
"lint": "oxlint \"src\""
|
"lint": "oxlint \"src\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@household/domain": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const applicationReady = true
|
export { calculateMonthlySettlement } from './settlement-engine'
|
||||||
|
|||||||
153
packages/application/src/settlement-engine.test.ts
Normal file
153
packages/application/src/settlement-engine.test.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
BillingCycleId,
|
||||||
|
BillingPeriod,
|
||||||
|
DomainError,
|
||||||
|
MemberId,
|
||||||
|
Money,
|
||||||
|
PurchaseEntryId
|
||||||
|
} from '@household/domain'
|
||||||
|
|
||||||
|
import { calculateMonthlySettlement } from './settlement-engine'
|
||||||
|
|
||||||
|
function fixtureBase() {
|
||||||
|
return {
|
||||||
|
cycleId: BillingCycleId.from('cycle-2026-03'),
|
||||||
|
period: BillingPeriod.fromString('2026-03'),
|
||||||
|
rent: Money.fromMajor('700.00', 'USD'),
|
||||||
|
utilities: Money.fromMajor('120.00', 'USD')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('calculateMonthlySettlement', () => {
|
||||||
|
test('3-member equal split with purchase offsets', () => {
|
||||||
|
const input = {
|
||||||
|
...fixtureBase(),
|
||||||
|
utilitySplitMode: 'equal' as const,
|
||||||
|
members: [
|
||||||
|
{ memberId: MemberId.from('alice'), active: true },
|
||||||
|
{ memberId: MemberId.from('bob'), active: true },
|
||||||
|
{ memberId: MemberId.from('carol'), active: true }
|
||||||
|
],
|
||||||
|
purchases: [
|
||||||
|
{
|
||||||
|
purchaseId: PurchaseEntryId.from('p1'),
|
||||||
|
payerId: MemberId.from('alice'),
|
||||||
|
amount: Money.fromMajor('30.00', 'USD')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = calculateMonthlySettlement(input)
|
||||||
|
|
||||||
|
expect(result.lines.map((line) => line.memberId.toString())).toEqual(['alice', 'bob', 'carol'])
|
||||||
|
expect(result.lines.map((line) => line.rentShare.amountMinor)).toEqual([23334n, 23333n, 23333n])
|
||||||
|
expect(result.lines.map((line) => line.utilityShare.amountMinor)).toEqual([4000n, 4000n, 4000n])
|
||||||
|
expect(result.lines.map((line) => line.purchaseOffset.amountMinor)).toEqual([
|
||||||
|
-2000n,
|
||||||
|
1000n,
|
||||||
|
1000n
|
||||||
|
])
|
||||||
|
expect(result.lines.map((line) => line.netDue.amountMinor)).toEqual([25334n, 28333n, 28333n])
|
||||||
|
expect(result.totalDue.amountMinor).toBe(82000n)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('4-member weighted utility split by days', () => {
|
||||||
|
const input = {
|
||||||
|
...fixtureBase(),
|
||||||
|
utilitySplitMode: 'weighted_by_days' as const,
|
||||||
|
members: [
|
||||||
|
{ memberId: MemberId.from('a'), active: true, utilityDays: 31 },
|
||||||
|
{ memberId: MemberId.from('b'), active: true, utilityDays: 31 },
|
||||||
|
{ memberId: MemberId.from('c'), active: true, utilityDays: 20 },
|
||||||
|
{ memberId: MemberId.from('d'), active: true, utilityDays: 10 }
|
||||||
|
],
|
||||||
|
purchases: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = calculateMonthlySettlement(input)
|
||||||
|
|
||||||
|
expect(result.lines.map((line) => line.utilityShare.amountMinor)).toEqual([
|
||||||
|
4044n,
|
||||||
|
4043n,
|
||||||
|
2609n,
|
||||||
|
1304n
|
||||||
|
])
|
||||||
|
expect(result.lines.map((line) => line.rentShare.amountMinor)).toEqual([
|
||||||
|
17500n,
|
||||||
|
17500n,
|
||||||
|
17500n,
|
||||||
|
17500n
|
||||||
|
])
|
||||||
|
expect(result.totalDue.amountMinor).toBe(82000n)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('5-member scenario with two purchases remains deterministic', () => {
|
||||||
|
const input = {
|
||||||
|
...fixtureBase(),
|
||||||
|
utilitySplitMode: 'equal' as const,
|
||||||
|
members: [
|
||||||
|
{ memberId: MemberId.from('m1'), active: true },
|
||||||
|
{ memberId: MemberId.from('m2'), active: true },
|
||||||
|
{ memberId: MemberId.from('m3'), active: true },
|
||||||
|
{ memberId: MemberId.from('m4'), active: true },
|
||||||
|
{ memberId: MemberId.from('m5'), active: true }
|
||||||
|
],
|
||||||
|
purchases: [
|
||||||
|
{
|
||||||
|
purchaseId: PurchaseEntryId.from('p1'),
|
||||||
|
payerId: MemberId.from('m1'),
|
||||||
|
amount: Money.fromMajor('25.00', 'USD')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
purchaseId: PurchaseEntryId.from('p2'),
|
||||||
|
payerId: MemberId.from('m4'),
|
||||||
|
amount: Money.fromMajor('41.00', 'USD')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = calculateMonthlySettlement(input)
|
||||||
|
|
||||||
|
expect(result.lines.map((line) => line.netDue.amountMinor)).toEqual([
|
||||||
|
15220n,
|
||||||
|
17720n,
|
||||||
|
17720n,
|
||||||
|
13620n,
|
||||||
|
17720n
|
||||||
|
])
|
||||||
|
expect(result.totalDue.amountMinor).toBe(82000n)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws if weighted split is selected without valid utility days', () => {
|
||||||
|
const input = {
|
||||||
|
...fixtureBase(),
|
||||||
|
utilitySplitMode: 'weighted_by_days' as const,
|
||||||
|
members: [
|
||||||
|
{ memberId: MemberId.from('a'), active: true, utilityDays: 31 },
|
||||||
|
{ memberId: MemberId.from('b'), active: true }
|
||||||
|
],
|
||||||
|
purchases: []
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => calculateMonthlySettlement(input)).toThrow(DomainError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws if purchase payer is not active', () => {
|
||||||
|
const input = {
|
||||||
|
...fixtureBase(),
|
||||||
|
utilitySplitMode: 'equal' as const,
|
||||||
|
members: [{ memberId: MemberId.from('a'), active: true }],
|
||||||
|
purchases: [
|
||||||
|
{
|
||||||
|
purchaseId: PurchaseEntryId.from('p1'),
|
||||||
|
payerId: MemberId.from('ghost'),
|
||||||
|
amount: Money.fromMajor('10.00', 'USD')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => calculateMonthlySettlement(input)).toThrow(DomainError)
|
||||||
|
})
|
||||||
|
})
|
||||||
205
packages/application/src/settlement-engine.ts
Normal file
205
packages/application/src/settlement-engine.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import {
|
||||||
|
DOMAIN_ERROR_CODE,
|
||||||
|
DomainError,
|
||||||
|
Money,
|
||||||
|
type SettlementInput,
|
||||||
|
type SettlementMemberInput,
|
||||||
|
type SettlementMemberLine,
|
||||||
|
type SettlementResult
|
||||||
|
} from '@household/domain'
|
||||||
|
|
||||||
|
interface ComputationMember {
|
||||||
|
input: SettlementMemberInput
|
||||||
|
rentShare: Money
|
||||||
|
utilityShare: Money
|
||||||
|
purchaseSharedCost: Money
|
||||||
|
purchasePaid: Money
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMemberState(
|
||||||
|
input: SettlementMemberInput,
|
||||||
|
currency: 'GEL' | 'USD'
|
||||||
|
): ComputationMember {
|
||||||
|
return {
|
||||||
|
input,
|
||||||
|
rentShare: Money.zero(currency),
|
||||||
|
utilityShare: Money.zero(currency),
|
||||||
|
purchaseSharedCost: Money.zero(currency),
|
||||||
|
purchasePaid: Money.zero(currency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureActiveMembers(
|
||||||
|
members: readonly SettlementMemberInput[]
|
||||||
|
): readonly SettlementMemberInput[] {
|
||||||
|
const active = members.filter((member) => member.active)
|
||||||
|
|
||||||
|
if (active.length === 0) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||||
|
'Settlement must include at least one active member'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNonNegativeMoney(label: string, value: Money): void {
|
||||||
|
if (value.isNegative()) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||||
|
`${label} must be non-negative`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumMoney(values: readonly Money[], currency: 'GEL' | 'USD'): Money {
|
||||||
|
return values.reduce((sum, current) => sum.add(current), Money.zero(currency))
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateWeightedUtilityDays(members: readonly SettlementMemberInput[]): readonly bigint[] {
|
||||||
|
const weights = members.map((member) => {
|
||||||
|
const days = member.utilityDays
|
||||||
|
|
||||||
|
if (days === undefined || !Number.isInteger(days) || days <= 0) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||||
|
`utilityDays must be a positive integer for member ${member.memberId.toString()}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return BigInt(days)
|
||||||
|
})
|
||||||
|
|
||||||
|
const total = weights.reduce((sum, current) => sum + current, 0n)
|
||||||
|
if (total <= 0n) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||||
|
'Total utility day weights must be positive'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return weights
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCurrencyConsistency(input: SettlementInput): void {
|
||||||
|
const currency = input.rent.currency
|
||||||
|
|
||||||
|
if (input.utilities.currency !== currency) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.CURRENCY_MISMATCH,
|
||||||
|
`Money operation currency mismatch: ${currency} vs ${input.utilities.currency}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const purchase of input.purchases) {
|
||||||
|
if (purchase.amount.currency !== currency) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.CURRENCY_MISMATCH,
|
||||||
|
`Money operation currency mismatch: ${currency} vs ${purchase.amount.currency}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateMonthlySettlement(input: SettlementInput): SettlementResult {
|
||||||
|
validateCurrencyConsistency(input)
|
||||||
|
ensureNonNegativeMoney('Rent', input.rent)
|
||||||
|
ensureNonNegativeMoney('Utilities', input.utilities)
|
||||||
|
|
||||||
|
const currency = input.rent.currency
|
||||||
|
const activeMembers = ensureActiveMembers(input.members)
|
||||||
|
|
||||||
|
const membersById = new Map<string, ComputationMember>(
|
||||||
|
activeMembers.map((member) => [member.memberId.toString(), createMemberState(member, currency)])
|
||||||
|
)
|
||||||
|
|
||||||
|
const rentShares = input.rent.splitEvenly(activeMembers.length)
|
||||||
|
for (const [index, member] of activeMembers.entries()) {
|
||||||
|
const state = membersById.get(member.memberId.toString())
|
||||||
|
if (!state) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
state.rentShare = rentShares[index] ?? Money.zero(currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
const utilityShares =
|
||||||
|
input.utilitySplitMode === 'equal'
|
||||||
|
? input.utilities.splitEvenly(activeMembers.length)
|
||||||
|
: input.utilities.splitByWeights(validateWeightedUtilityDays(activeMembers))
|
||||||
|
|
||||||
|
for (const [index, member] of activeMembers.entries()) {
|
||||||
|
const state = membersById.get(member.memberId.toString())
|
||||||
|
if (!state) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
state.utilityShare = utilityShares[index] ?? Money.zero(currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const purchase of input.purchases) {
|
||||||
|
ensureNonNegativeMoney('Purchase amount', purchase.amount)
|
||||||
|
|
||||||
|
const payer = membersById.get(purchase.payerId.toString())
|
||||||
|
if (!payer) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||||
|
`Purchase payer is not an active member: ${purchase.payerId.toString()}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
payer.purchasePaid = payer.purchasePaid.add(purchase.amount)
|
||||||
|
|
||||||
|
const purchaseShares = purchase.amount.splitEvenly(activeMembers.length)
|
||||||
|
for (const [index, member] of activeMembers.entries()) {
|
||||||
|
const state = membersById.get(member.memberId.toString())
|
||||||
|
if (!state) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
state.purchaseSharedCost = state.purchaseSharedCost.add(
|
||||||
|
purchaseShares[index] ?? Money.zero(currency)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: SettlementMemberLine[] = activeMembers.map((member) => {
|
||||||
|
const state = membersById.get(member.memberId.toString())
|
||||||
|
if (!state) {
|
||||||
|
throw new DomainError(
|
||||||
|
DOMAIN_ERROR_CODE.INVALID_SETTLEMENT_INPUT,
|
||||||
|
`Missing member state: ${member.memberId.toString()}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseOffset = state.purchaseSharedCost.subtract(state.purchasePaid)
|
||||||
|
const netDue = state.rentShare.add(state.utilityShare).add(purchaseOffset)
|
||||||
|
|
||||||
|
return {
|
||||||
|
memberId: member.memberId,
|
||||||
|
rentShare: state.rentShare,
|
||||||
|
utilityShare: state.utilityShare,
|
||||||
|
purchaseOffset,
|
||||||
|
netDue,
|
||||||
|
explanations: [
|
||||||
|
`rent_share_minor=${state.rentShare.amountMinor.toString()}`,
|
||||||
|
`utility_share_minor=${state.utilityShare.amountMinor.toString()}`,
|
||||||
|
`purchase_paid_minor=${state.purchasePaid.amountMinor.toString()}`,
|
||||||
|
`purchase_shared_minor=${state.purchaseSharedCost.amountMinor.toString()}`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalDue = sumMoney(
|
||||||
|
lines.map((line) => line.netDue),
|
||||||
|
currency
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
cycleId: input.cycleId,
|
||||||
|
period: input.period,
|
||||||
|
lines,
|
||||||
|
totalDue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
"name": "@household/domain",
|
"name": "@household/domain",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun build src/index.ts --outdir dist --target bun",
|
"build": "bun build src/index.ts --outdir dist --target bun",
|
||||||
"typecheck": "tsgo --project tsconfig.json --noEmit",
|
"typecheck": "tsgo --project tsconfig.json --noEmit",
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ export const DOMAIN_ERROR_CODE = {
|
|||||||
INVALID_SPLIT_PARTS: 'INVALID_SPLIT_PARTS',
|
INVALID_SPLIT_PARTS: 'INVALID_SPLIT_PARTS',
|
||||||
INVALID_SPLIT_WEIGHTS: 'INVALID_SPLIT_WEIGHTS',
|
INVALID_SPLIT_WEIGHTS: 'INVALID_SPLIT_WEIGHTS',
|
||||||
INVALID_BILLING_PERIOD: 'INVALID_BILLING_PERIOD',
|
INVALID_BILLING_PERIOD: 'INVALID_BILLING_PERIOD',
|
||||||
INVALID_ENTITY_ID: 'INVALID_ENTITY_ID'
|
INVALID_ENTITY_ID: 'INVALID_ENTITY_ID',
|
||||||
|
INVALID_SETTLEMENT_INPUT: 'INVALID_SETTLEMENT_INPUT'
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
Reference in New Issue
Block a user