@@ -0,0 +1,554 @@
|
|||||||
|
# RayLab Bot Debt API
|
||||||
|
|
||||||
|
Base path: /api/bot/debt/
|
||||||
|
|
||||||
|
This document is the API contract for the Debt Management endpoints implemented in RayLab Core. The contents are generated from the actual code in the repository and reflect the real request/response shapes and behavior. Do not assume fields or endpoints that are not present in code.
|
||||||
|
|
||||||
|
Authentication
|
||||||
|
- All endpoints under /api/bot/debt/* require JWT authentication via Authorization: Bearer <token> (JwtAuthGuard).
|
||||||
|
- CurrentUserGuard maps the verified identity to internal User (via SyncIdentityHandler for external identities).
|
||||||
|
- All responses are wrapped as { success: true, data: ... } on success. Errors are returned via the global exception filter as { success: false, error: { code, message } }.
|
||||||
|
|
||||||
|
NOTE: See the "Authentication Gap" section below for how Telegram Bots should obtain JWTs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
CONTRACT FORMAT (per-endpoint)
|
||||||
|
Method Path
|
||||||
|
Authentication
|
||||||
|
Purpose
|
||||||
|
Request (example JSON)
|
||||||
|
Response success (example JSON)
|
||||||
|
Possible errors (HTTP code + example)
|
||||||
|
Notes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
1) POST /api/bot/debt/users/sync
|
||||||
|
Authentication: Bearer JWT required
|
||||||
|
Purpose: Sync a Telegram identity to an internal RayLab user via SyncIdentityHandler and return internal user summary.
|
||||||
|
Request DTO: SyncUserDto
|
||||||
|
Example request JSON:
|
||||||
|
{
|
||||||
|
"telegramUserId": "123456789",
|
||||||
|
"displayName": "Rayyan"
|
||||||
|
}
|
||||||
|
Response success example:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "<internal-user-uuid>",
|
||||||
|
"username": "rayyan",
|
||||||
|
"name": "rayyan"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Possible errors:
|
||||||
|
- 400 Bad Request: { success: false, error: { code: 'BAD_REQUEST', message: 'telegramUserId required' } }
|
||||||
|
- 401 / 403 may be returned by JwtAuthGuard/CurrentUserGuard depending on authentication/identity validity.
|
||||||
|
Notes:
|
||||||
|
- This endpoint uses SyncIdentityHandler under the hood to find/create the internal User based on an external identity with sub `telegram:{telegramUserId}`.
|
||||||
|
- Calling this endpoint will create or update a User via the project identity patterns. Repeated calls for the same telegramUserId must return the same internal user (no duplicates).
|
||||||
|
- The endpoint is guarded by JwtAuthGuard + CurrentUserGuard; the caller must present a valid JWT. See "Authentication Gap".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
2) POST /api/bot/debt/groups
|
||||||
|
Authentication: Bearer JWT required
|
||||||
|
Purpose: Create a new Debt Group. The authenticated user becomes the owner.
|
||||||
|
Request DTO: CreateGroupDto
|
||||||
|
Example request JSON:
|
||||||
|
{ "name": "Keluarga" }
|
||||||
|
Response success example:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { "id": "<group-uuid>", "publicId": "RL-1A2B3C", "name": "Keluarga" }
|
||||||
|
}
|
||||||
|
Possible errors:
|
||||||
|
- 400 Bad Request: { success:false, error:{ code:'BAD_REQUEST', message:'name required' } }
|
||||||
|
- 401 Unauthorized / 403 Forbidden depending on JWT/identity
|
||||||
|
Notes:
|
||||||
|
- After create, owner is automatically added as a member.
|
||||||
|
- publicId is safe to share and used for joining.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
3) GET /api/bot/debt/groups
|
||||||
|
Authentication: Bearer JWT required
|
||||||
|
Purpose: List Debt Groups the current user is a member of.
|
||||||
|
Request: none
|
||||||
|
Response success example:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "id": "<group-uuid>", "publicId": "RL-1A2B3C", "name": "Keluarga", "ownerId": "<owner-uuid>" },
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Possible errors:
|
||||||
|
- 401 Unauthorized
|
||||||
|
Notes:
|
||||||
|
- Membership is determined by DebtGroupMember entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
4) GET /api/bot/debt/groups/:id
|
||||||
|
Authentication: Bearer JWT required
|
||||||
|
Purpose: Get group detail (people list and basic info). User must be a member.
|
||||||
|
Request: none
|
||||||
|
Response success example:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "<group-uuid>",
|
||||||
|
"publicId": "RL-1A2B3C",
|
||||||
|
"name": "Keluarga",
|
||||||
|
"people": [ { "id": "<person-uuid>", "name": "Rayyan" }, ... ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Possible errors:
|
||||||
|
- 404 Not Found: { success:false, error:{ code:'NOT_FOUND', message:'Group not found' } }
|
||||||
|
- 403 Forbidden: { success:false, error:{ code:'FORBIDDEN', message:'Not a member' } }
|
||||||
|
Notes:
|
||||||
|
- Only members can view group detail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
5) POST /api/bot/debt/groups/:publicId/join
|
||||||
|
Authentication: Bearer JWT required
|
||||||
|
Purpose: Join a group by its publicId (anyone who has publicId can join as member without owner approval).
|
||||||
|
Request: none
|
||||||
|
Example request: POST /api/bot/debt/groups/RL-1A2B3C/join
|
||||||
|
Response success examples:
|
||||||
|
- On new membership created:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { "id": "<membership-uuid>" }
|
||||||
|
}
|
||||||
|
- If already a member:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { "ok": true }
|
||||||
|
}
|
||||||
|
Possible errors:
|
||||||
|
- 404 Not Found: { success:false, error:{ code:'NOT_FOUND', message:'Group not found' } }
|
||||||
|
Notes:
|
||||||
|
- Unique constraint prevents duplicate membership.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
6) POST /api/bot/debt/groups/:id/leave
|
||||||
|
Authentication: Bearer JWT required
|
||||||
|
Purpose: Current user leaves the group.
|
||||||
|
Request: none
|
||||||
|
Response success example:
|
||||||
|
{ "success": true, "data": { "ok": true } }
|
||||||
|
Possible errors:
|
||||||
|
- 404 Not Found: Group not found
|
||||||
|
- 401/403: unauthorized
|
||||||
|
Notes:
|
||||||
|
- Implementation deletes DebtGroupMember entries for that (groupId, userId).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
7) POST /api/bot/debt/groups/:id/people
|
||||||
|
Authentication: Bearer JWT required (Owner only)
|
||||||
|
Purpose: Owner adds a Person to the Group.
|
||||||
|
Request DTO: CreatePersonDto
|
||||||
|
Example request JSON:
|
||||||
|
{ "name": "Rayyan" }
|
||||||
|
Response success example:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { "id": "<person-uuid>", "name": "Rayyan" }
|
||||||
|
}
|
||||||
|
Possible errors:
|
||||||
|
- 404 Not Found: Group not found
|
||||||
|
- 403 Forbidden: { success:false, error:{ code:'FORBIDDEN', message:'Only owner can add person' } }
|
||||||
|
- 400 Bad Request: { success:false, error:{ code:'BAD_REQUEST', message:'Person with same name already exists in group' } }
|
||||||
|
Notes:
|
||||||
|
- Person names are unique per group (DB @@unique([groupId,name])).
|
||||||
|
- Person != Telegram User (see Person section).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
8) GET /api/bot/debt/groups/:id/people
|
||||||
|
Authentication: Bearer JWT required
|
||||||
|
Purpose: List People inside a group (member-only).
|
||||||
|
Request: none
|
||||||
|
Response success example:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [ { "id": "<person-uuid>", "name": "Rayyan" }, ... ]
|
||||||
|
}
|
||||||
|
Possible errors:
|
||||||
|
- 403 Forbidden: Not a member
|
||||||
|
Notes:
|
||||||
|
- Only active (isDeleted=false) persons are returned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
9) DELETE /api/bot/debt/groups/:id/people/:personId
|
||||||
|
Authentication: Bearer JWT required (Owner only)
|
||||||
|
Purpose: Soft-delete a Person from the Group.
|
||||||
|
Request: none
|
||||||
|
Response success example:
|
||||||
|
{ "success": true, "data": null }
|
||||||
|
Possible errors:
|
||||||
|
- 404 Not Found: Group not found
|
||||||
|
- 403 Forbidden: Only owner can remove person
|
||||||
|
Notes:
|
||||||
|
- Implementation sets isDeleted=true (soft delete). Historical transactions remain and are not removed.
|
||||||
|
- After soft-delete this Person cannot be used for new transactions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
10) POST /api/bot/debt/groups/:id/transactions/debt
|
||||||
|
Authentication: Bearer JWT required (Member)
|
||||||
|
Purpose: Create a DEBT transaction (ledger entry).
|
||||||
|
Request DTO: CreateTransactionDto (example):
|
||||||
|
{
|
||||||
|
"from": "Rayyan",
|
||||||
|
"to": "Krisda",
|
||||||
|
"price": "17.000",
|
||||||
|
"description": "Makan siang",
|
||||||
|
"date": "2026/09/01", // optional, format yyyy/mm/dd
|
||||||
|
"requestId": "telegram-update-12345"
|
||||||
|
}
|
||||||
|
Response success example (created DEBT transaction model as returned by Prisma, wrapped):
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "<tx-uuid>",
|
||||||
|
"groupId": "<group-uuid>",
|
||||||
|
"fromPersonId": "<person-uuid>",
|
||||||
|
"toPersonId": "<person-uuid>",
|
||||||
|
"amount": 17000,
|
||||||
|
"type": "DEBT",
|
||||||
|
"description": "Makan siang",
|
||||||
|
"transactionDate": "2026-09-01T00:00:00.000Z",
|
||||||
|
"createdAt": "2026-09-01T10:00:00.000Z",
|
||||||
|
"createdByUserId": "<user-uuid>",
|
||||||
|
"requestId": "telegram-update-12345"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Possible errors:
|
||||||
|
- 400 Bad Request:
|
||||||
|
- "from and to required"
|
||||||
|
- "from and to cannot be same"
|
||||||
|
- "Person not found in group"
|
||||||
|
- "requestId is required for idempotency"
|
||||||
|
- "Nominal tidak valid. Gunakan format seperti 17000 atau 17.000."
|
||||||
|
- "Nominal harus lebih besar dari 0"
|
||||||
|
- "Tanggal tidak valid. Gunakan format yyyy/mm/dd"
|
||||||
|
- 403 Forbidden: Not a member
|
||||||
|
- 404 Not Found: Group not found
|
||||||
|
Notes:
|
||||||
|
- requestId is required by service to ensure idempotency; DB also has unique(groupId, requestId) as last-resort protection.
|
||||||
|
- price is a string in request; service parses dot separators and stores BigInt.
|
||||||
|
- transactionDate stored as date-only (Asia/Jakarta by default if omitted).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
11) POST /api/bot/debt/groups/:id/transactions/payment
|
||||||
|
Authentication: Bearer JWT required (Member)
|
||||||
|
Purpose: Create a PAYMENT ledger entry. If payment exceeds existing debt, a reverse DEBT transaction is created automatically for the remainder.
|
||||||
|
Request DTO: CreateTransactionDto (same shape as DEBT)
|
||||||
|
Example request JSON:
|
||||||
|
{
|
||||||
|
"from": "Rayyan",
|
||||||
|
"to": "Krisda",
|
||||||
|
"price": "60.000",
|
||||||
|
"description": "Bayar lebih",
|
||||||
|
"requestId": "telegram-update-67890"
|
||||||
|
}
|
||||||
|
Response success examples:
|
||||||
|
- If payment does not produce remainder (no overpayment):
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { /* PAYMENT transaction object like DEBT example */ }
|
||||||
|
}
|
||||||
|
- If payment > existing debt and remainder produced:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"payment": { /* payment transaction object */ },
|
||||||
|
"remainder": { /* created DEBT transaction reversed, i.e. to->from */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Possible errors: same as DEBT endpoint (validation, membership, group not found)
|
||||||
|
Notes:
|
||||||
|
- Behavior: create PAYMENT ledger entry; compute existing net between the two persons (aggregating ledger), then if payment > existing positive debt, create a new DEBT transaction in opposite direction for remainder with description: "Sisa pembayaran hutang dari {creatorName}".
|
||||||
|
- The ledger is immutable: PAYMENT and resultant DEBT(reverse) are separate records.
|
||||||
|
- Avoid double-counting: the net is computed from ledger BEFORE the new PAYMENT; after PAYMENT + possible reverse DEBT, aggregate net will reflect correct result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
12) GET /api/bot/debt/groups/:id/transactions
|
||||||
|
Authentication: Bearer JWT required (Member)
|
||||||
|
Purpose: Retrieve raw ledger history for group.
|
||||||
|
Query params: page (optional), limit (optional) — current implementation ignores advanced paging; controller accepts page & limit but service returns all transactions for group.
|
||||||
|
Response success example:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "id": "<tx-uuid>", "from": "Rayyan", "to": "Krisda", "amount": "17000", "type": "DEBT", "description": "Makan", "transactionDate": "2026-09-01", "createdAt": "2026-09-01T10:00:00Z" },
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Possible errors:
|
||||||
|
- 403 Forbidden: Not a member
|
||||||
|
Notes:
|
||||||
|
- Results are ordered by transactionDate ASC, then createdAt ASC to ensure deterministic ordering.
|
||||||
|
- This endpoint is RAW ledger history (detail), not summary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
13) GET /api/bot/debt/groups/:id/summary
|
||||||
|
Authentication: Bearer JWT required (Member)
|
||||||
|
Purpose: Compute CURRENT NET DEBT for the group using global netting (read-only). The algorithm aggregates ledger and performs deterministic greedy settlement. It does not change the ledger.
|
||||||
|
Response success example:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "from": "Rayyan", "to": "Krisda", "amount": "22000" },
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
If no debts remain:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": []
|
||||||
|
}
|
||||||
|
Possible errors:
|
||||||
|
- 403 Forbidden: Not a member
|
||||||
|
Notes:
|
||||||
|
- Summary output is a settlement list: who should pay whom and how much, computed by the server's netting algorithm.
|
||||||
|
- Different settlement variants may be valid; the server returns one deterministic settlement (greedy algorithm).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
14) GET /api/bot/debt/groups/:id/detail
|
||||||
|
Authentication: Bearer JWT required (Member)
|
||||||
|
Purpose: Same as GET /transactions — return RAW LEDGER HISTORY ordered by transactionDate ASC, createdAt ASC.
|
||||||
|
Response: same as /transactions endpoint.
|
||||||
|
Notes: For Telegram commands, /TampilkanDetailHutang should call this endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
ERROR MAPPING
|
||||||
|
RayLab Core uses a global exception filter which returns errors as:
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": { "code": "<exception.code || 'INTERNAL_ERROR'>", "message": "<exception.message>" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Common error cases (HTTP status and example response):
|
||||||
|
- 400 Bad Request
|
||||||
|
- Example: invalid amount
|
||||||
|
- {"success": false, "error": {"code":"BAD_REQUEST","message":"Nominal tidak valid. Gunakan format seperti 17000 atau 17.000."}}
|
||||||
|
- 403 Forbidden
|
||||||
|
- Example: not a member or not owner
|
||||||
|
- {"success": false, "error": {"code":"FORBIDDEN","message":"Not a member"}}
|
||||||
|
- 404 Not Found
|
||||||
|
- Example: Group not found
|
||||||
|
- {"success": false, "error": {"code":"NOT_FOUND","message":"Group not found"}}
|
||||||
|
- 500 Internal Error: unexpected exceptions
|
||||||
|
|
||||||
|
Exact messages returned are the exception.message strings thrown by the service (see service code for exact strings).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
TELEGRAM BOT INTEGRATION (GUIDE)
|
||||||
|
Runtime flow (recommended):
|
||||||
|
Telegram User -> Telegram Bot -> RayLab Core (/api/bot/debt) with JWT auth
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- Each API call requires JWT authentication (JwtAuthGuard). The CurrentUserGuard maps the JWT-validated identity to internal User.
|
||||||
|
- The codebase does not provide an automatic JWT issuance endpoint for Telegram Bot on behalf of a Telegram user. Auth flow is based on existing RayLab authentication (Authentik / OIDC / internal JWT generation).
|
||||||
|
|
||||||
|
Recommended integration options for Telegram Bot developers (choose one depending on security & user experience):
|
||||||
|
1) Per-user interactive login (most secure):
|
||||||
|
- The Telegram Bot prompts the user to authenticate via the RayLab web login (OIDC) and obtain a RayLab internal access token. The bot instructs user to paste a short-lived token or do a one-time link flow.
|
||||||
|
- Bot uses that token to call /api/bot/debt with Authorization: Bearer <token>.
|
||||||
|
- Pros: actions executed under user identity; createdByUserId matches the real user.
|
||||||
|
- Cons: requires user interaction and web flow.
|
||||||
|
|
||||||
|
2) Service-account with explicit recorded actor (less ideal):
|
||||||
|
- Bot uses a service account JWT to call API. createdByUserId will be service-account id.
|
||||||
|
- To preserve traceability, Bot includes requestId and includes in request.body metadata about the Telegram user (but server currently does not accept/verify a claimed telegramUserId). This is less secure because createdByUserId won't match Telegram user.
|
||||||
|
|
||||||
|
3) Implement server-side mapping endpoint (recommended by platform team):
|
||||||
|
- Add a secure server endpoint that accepts a Telegram update signed or validated by bot token and exchanges it for a user-specific JWT using SyncIdentityHandler and internal authentication service.
|
||||||
|
- This requires changes outside the current feature and must follow security review.
|
||||||
|
|
||||||
|
Authentication Gap (explicit):
|
||||||
|
- The repository currently expects callers to present JWTs. There is no built-in endpoint to exchange a Telegram user_id for a JWT without user authentication.
|
||||||
|
- As a result, Telegram Bot developers must either obtain per-user JWTs via the normal login flow, or use a service account JWT (with tradeoffs), or request project owners to implement a secure bot-to-server auth bridge.
|
||||||
|
|
||||||
|
When to call /users/sync?
|
||||||
|
- /users/sync maps a Telegram external identity into internal User via SyncIdentityHandler. It should be called when you need the internal user to exist or to refresh data. If you follow per-user login (option 1), explicit /users/sync may not be needed. If you use a server-side bridging approach, call /users/sync as part of the bridging sequence to ensure the internal user exists.
|
||||||
|
- Do not call /users/sync unauthenticated. The endpoint is guarded and requires a JWT.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
TELEGRAM COMMAND → API MAPPING (examples)
|
||||||
|
1) Create Group
|
||||||
|
Telegram: /BuatGroup Keluarga
|
||||||
|
Bot: POST /api/bot/debt/groups
|
||||||
|
Request body: { "name": "Keluarga" }
|
||||||
|
Response: returns group publicId (e.g. RL-1A2B3C) -> Bot shares this with group members to join.
|
||||||
|
|
||||||
|
2) Join Group
|
||||||
|
Telegram: /MasukGroup RL-1A2B3C
|
||||||
|
Bot: POST /api/bot/debt/groups/RL-1A2B3C/join
|
||||||
|
|
||||||
|
3) Add Person
|
||||||
|
Telegram: /TambahOrang Rayyan
|
||||||
|
Bot: POST /api/bot/debt/groups/:id/people
|
||||||
|
Body: { "name": "Rayyan" }
|
||||||
|
Note: only owner can add person (owner determined by JWT caller).
|
||||||
|
|
||||||
|
4) Add Debt
|
||||||
|
Telegram: /TambahHutang Rayyan - Krisda - 17.000 - Makan siang
|
||||||
|
Bot: POST /api/bot/debt/groups/:id/transactions/debt
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"from": "Rayyan",
|
||||||
|
"to": "Krisda",
|
||||||
|
"price": "17.000",
|
||||||
|
"description": "Makan siang",
|
||||||
|
"requestId": "telegram-update-12345"
|
||||||
|
}
|
||||||
|
If date provided (yyyy/mm/dd) include "date" field.
|
||||||
|
|
||||||
|
5) Payment
|
||||||
|
Telegram: /BayarHutang Rayyan Krisda 10.000
|
||||||
|
Bot: POST /api/bot/debt/groups/:id/transactions/payment
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"from": "Rayyan",
|
||||||
|
"to": "Krisda",
|
||||||
|
"price": "10.000",
|
||||||
|
"requestId": "telegram-update-67890"
|
||||||
|
}
|
||||||
|
|
||||||
|
6) Detail
|
||||||
|
Telegram: /TampilkanDetailHutang
|
||||||
|
Bot: GET /api/bot/debt/groups/:id/detail
|
||||||
|
Bot shows raw ledger lines as returned.
|
||||||
|
|
||||||
|
7) Summary
|
||||||
|
Telegram: /TampilkanKesimpulanHutang
|
||||||
|
Bot: GET /api/bot/debt/groups/:id/summary
|
||||||
|
Bot shows summarized settlement lines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
EXAMPLE END-TO-END FLOW (minimal)
|
||||||
|
1) Owner Alice authenticates via RayLab OIDC, obtains JWT (out of scope for bot automation). Bot now holds Alice's JWT for use.
|
||||||
|
2) Alice: /BuatGroup Keluarga
|
||||||
|
Bot: POST /api/bot/debt/groups with Alice's JWT -> receives { publicId: "RL-1A2B3C" }
|
||||||
|
Bot replies: "Group created. Public ID RL-1A2B3C"
|
||||||
|
3) Bob obtains publicId and calls /MasukGroup RL-1A2B3C (after Bob has a JWT)
|
||||||
|
Bot: POST /api/bot/debt/groups/RL-1A2B3C/join with Bob's JWT
|
||||||
|
4) Alice (owner) adds people:
|
||||||
|
/TambahOrang Rayyan -> POST /api/bot/debt/groups/:id/people { name: 'Rayyan' }
|
||||||
|
/TambahOrang Krisda -> POST /... { name: 'Krisda' }
|
||||||
|
5) Bob (member) adds debt:
|
||||||
|
/TambahHutang Rayyan - Krisda - 17000 - Makan siang -> POST transactions/debt with his JWT and requestId based on message id
|
||||||
|
6) Someone asks /TampilkanKesimpulanHutang -> GET summary -> Bot displays settlement
|
||||||
|
7) Someone asks /TampilkanDetailHutang -> GET detail -> Bot displays ledger lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
IDEMPOTENCY
|
||||||
|
- requestId in CreateTransactionDto is required by service. The DB has a unique constraint @@unique([groupId, requestId]).
|
||||||
|
- Behavior:
|
||||||
|
- If a request with the same (groupId, requestId) already exists, service returns the existing transaction and does not create a duplicate.
|
||||||
|
- In concurrent scenarios, DB unique constraint is the final line of defense; clients should retry on unique-violation errors by re-fetching the existing transaction or returning success.
|
||||||
|
|
||||||
|
Recommendation for bot developers:
|
||||||
|
- Use a stable requestId per Telegram update (e.g., `telegram:update:{update_id}` or `telegram:message:{chat_id}:{message_id}`) to allow safe retries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
SECURITY REVIEW (quick)
|
||||||
|
1) JWT validation
|
||||||
|
- JwtAuthGuard verifies external JWKS (Authentik) or internal secret. Use Authorization: Bearer <token>.
|
||||||
|
2) Identity spoofing
|
||||||
|
- /users/sync is guarded by JwtAuthGuard; unauthorized clients cannot arbitrarily create arbitrary internal users.
|
||||||
|
- However, there is no remote-check confirming that the JWT presented *belongs to* the telegramUserId included in /users/sync body. The server uses SyncIdentityHandler to map identity from verified claims when CurrentUserGuard is used; but the sync endpoint currently expects authenticated callers. If a bot uses a service account JWT, it can call /users/sync for any telegramUserId — the system will create internal users based on given sub `telegram:{id}`. This is a gap: a bot acting on behalf of a Telegram user must obtain a JWT THAT represents that user, or an auth bridge must be implemented.
|
||||||
|
3) Group membership and owner
|
||||||
|
- Owner checks compare req.currentUser.id to group.ownerId (server-side). This is enforced server-side.
|
||||||
|
4) Person name manipulation
|
||||||
|
- Person lookup in createTransaction is by name within groupId and isDeleted=false. Be careful: names are the identity used in the API; bots must ensure proper escaping and normalization when parsing from Telegram.
|
||||||
|
5) requestId manipulation
|
||||||
|
- requestId is honored by server and unique constraint used; ensure bots generate collision-resistant ids.
|
||||||
|
6) DTO whitelist
|
||||||
|
- Global ValidationPipe is enabled (whitelist:true, transform:true, forbidNonWhitelisted:true) so unexpected fields are rejected.
|
||||||
|
7) SQL/Prisma injection
|
||||||
|
- Prisma queries use parameterized API. However, person lookup is by name; ensure the bot sends sanitized strings. Prisma prevents SQL injection when using its query methods.
|
||||||
|
8) Deleted Person
|
||||||
|
- Soft-deleted Person cannot be used for new transactions; queries use isDeleted=false. Historical transactions remain readable.
|
||||||
|
|
||||||
|
Security finding / gap (explicit):
|
||||||
|
- There is no built-in server-side flow that allows the Telegram Bot to obtain a JWT on behalf of a Telegram user without user interaction. This means a bot cannot impersonate the real user securely unless that user performs the standard authentication flow (OIDC). If you plan to let Bot perform actions as real users without user login, you must implement a secure delegation mechanism (requires architectural review).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
SWAGGER
|
||||||
|
- RayLab Core includes Swagger support (if SWAGGER_ENABLED=true in config, docs available). The debt controller classes use DTOs; if Swagger is enabled the endpoints will appear. The project already includes @nestjs/swagger — enabling will include these endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
TESTS / BUILD / MIGRATION STATUS
|
||||||
|
- Unit tests (run locally during development): all unit tests in this repo passed in this environment.
|
||||||
|
- Test suites run: 10
|
||||||
|
- Tests: 29 passed
|
||||||
|
- Build: tsc compile succeeded in this environment.
|
||||||
|
- Prisma generate: succeeded (Prisma Client generated locally).
|
||||||
|
- Prisma migrate dev --name add_debt_models: NOT APPLIED in this environment due to unreachable DB (P1001: Can't reach database server at `postgres-raylab:5432`).
|
||||||
|
- Action required: run migration in environment with reachable DB; do not run migrate reset in production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
FILES CHANGED (by implementation)
|
||||||
|
- prisma/schema.prisma (added debt models + backrefs)
|
||||||
|
- src/modules/bot-debt/debt.module.ts
|
||||||
|
- src/modules/bot-debt/presentation/debt.controller.ts
|
||||||
|
- src/modules/bot-debt/presentation/dto/sync-user.dto.ts
|
||||||
|
- src/modules/bot-debt/presentation/dto/create-group.dto.ts
|
||||||
|
- src/modules/bot-debt/presentation/dto/create-person.dto.ts
|
||||||
|
- src/modules/bot-debt/presentation/dto/create-transaction.dto.ts
|
||||||
|
- src/modules/bot-debt/application/debt.service.ts
|
||||||
|
- src/app.module.ts
|
||||||
|
- tests/unit/debt.service.spec.ts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
REMAINING ISSUES / ACTIONS FOR OPERATIONAL TEAM
|
||||||
|
1) Apply Prisma migration in environment where database is reachable:
|
||||||
|
- npx prisma generate
|
||||||
|
- npx prisma migrate dev --name add_debt_models
|
||||||
|
- Commit the generated migration directory if your process requires it.
|
||||||
|
2) Decide on authentication flow for Telegram Bot:
|
||||||
|
- Option A (recommended): Bot guides users to obtain per-user JWT via OIDC login; Bot uses users' JWT to call API.
|
||||||
|
- Option B: Implement a secure server-side exchange for Telegram identities to internal JWTs (requires new endpoint & security review).
|
||||||
|
3) Optionally make requestId DB column NOT NULL to enforce client discipline. (Current code requires requestId in service; DB column is nullable — left intentionally to maintain compatibility.)
|
||||||
|
4) Add integration tests (optional): minimal end-to-end test that runs migrate + spins up app and exercises endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
SUMMARY STATEMENT
|
||||||
|
This document is the canonical API contract for the Debt Management endpoints implemented at /api/bot/debt/. It contains concrete request & response JSON, error mapping, identity & integration guidance for Telegram Bot implementers, and notes about security and idempotency.
|
||||||
|
|
||||||
|
Next steps for Bot developers:
|
||||||
|
- Decide how your bot will obtain JWTs (per-user or service-account). If per-user, implement a login flow; if service-account, accept createdByUserId will be the service account.
|
||||||
|
- Implement stable requestId generation per Telegram update to ensure idempotency.
|
||||||
|
- Use DTO field names exactly as documented above.
|
||||||
|
|
||||||
|
If you want, I can now:
|
||||||
|
- Add explicit Swagger decorators/examples to the controllers (so generated API docs include exact DTOs and examples), and
|
||||||
|
- Add a small integration test (if you can provide reachable test DB credentials or run the migration locally and provide migration status).
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +32,11 @@ model User {
|
|||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
mediaObjects MediaObject[]
|
mediaObjects MediaObject[]
|
||||||
lastGroupHash String? @db.Text
|
lastGroupHash String? @db.Text
|
||||||
|
// relations for debt module
|
||||||
|
debtGroupsOwned DebtGroup[]
|
||||||
|
debtGroupMembers DebtGroupMember[]
|
||||||
|
createdDebtTransactions DebtTransaction[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -188,3 +193,79 @@ model Application {
|
|||||||
@@index([applicationsClaim], name: "Application_applicationsClaim_idx")
|
@@index([applicationsClaim], name: "Application_applicationsClaim_idx")
|
||||||
@@map("Application")
|
@@map("Application")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debt management models for /api/bot/debt
|
||||||
|
|
||||||
|
enum DebtTransactionType {
|
||||||
|
DEBT
|
||||||
|
PAYMENT
|
||||||
|
}
|
||||||
|
|
||||||
|
model DebtGroup {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
publicId String @unique @db.VarChar(20) // e.g. RL-X7K29P
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
ownerId String @db.Uuid
|
||||||
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
members DebtGroupMember[]
|
||||||
|
people DebtPerson[]
|
||||||
|
transactions DebtTransaction[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([publicId])
|
||||||
|
@@index([ownerId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model DebtGroupMember {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
group DebtGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
groupId String @db.Uuid
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
userId String @db.Uuid
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([groupId, userId])
|
||||||
|
@@index([groupId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model DebtPerson {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
group DebtGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
groupId String @db.Uuid
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
isDeleted Boolean @default(false)
|
||||||
|
// relations back to transactions
|
||||||
|
transactionsFrom DebtTransaction[] @relation("fromPerson")
|
||||||
|
transactionsTo DebtTransaction[] @relation("toPerson")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([groupId, name])
|
||||||
|
@@index([groupId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model DebtTransaction {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
group DebtGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
groupId String @db.Uuid
|
||||||
|
fromPerson DebtPerson @relation("fromPerson", fields: [fromPersonId], references: [id], onDelete: Restrict)
|
||||||
|
fromPersonId String @db.Uuid
|
||||||
|
toPerson DebtPerson @relation("toPerson", fields: [toPersonId], references: [id], onDelete: Restrict)
|
||||||
|
toPersonId String @db.Uuid
|
||||||
|
amount BigInt @db.BigInt
|
||||||
|
type DebtTransactionType
|
||||||
|
description String?
|
||||||
|
transactionDate DateTime @db.Date
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
createdByUserId String @db.Uuid
|
||||||
|
createdByUser User @relation(fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||||
|
requestId String? @db.VarChar(255)
|
||||||
|
|
||||||
|
@@index([groupId])
|
||||||
|
@@index([fromPersonId])
|
||||||
|
@@index([toPersonId])
|
||||||
|
@@unique([groupId, requestId])
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import { HealthModule } from './modules/health/health.module';
|
|||||||
import { AuthorizationModule } from './modules/authorization/authorization.module';
|
import { AuthorizationModule } from './modules/authorization/authorization.module';
|
||||||
import { AuditModule } from './modules/audit/audit.module';
|
import { AuditModule } from './modules/audit/audit.module';
|
||||||
import { ApplicationModule } from './modules/application/application.module';
|
import { ApplicationModule } from './modules/application/application.module';
|
||||||
|
import { DebtModule } from './modules/bot-debt/debt.module';
|
||||||
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
|
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
// Load .env files depending on NODE_ENV. Default to development .env
|
// Load .env files depending on NODE_ENV. Default to development .env
|
||||||
envFilePath: process.env.NODE_ENV === 'production' ? '.env.production' : '.env',
|
envFilePath: process.env.NODE_ENV === 'production' ? '.env.production' : '.env',
|
||||||
@@ -41,6 +43,7 @@ import { ApplicationModule } from './modules/application/application.module';
|
|||||||
AuthorizationModule,
|
AuthorizationModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
ApplicationModule,
|
ApplicationModule,
|
||||||
|
DebtModule,
|
||||||
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { Injectable, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../../shared/prisma.service';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { SyncIdentityHandler } from '../../identity/application/handlers/user/sync-identity.handler';
|
||||||
|
import { IdentityData } from '../../../core/auth/interfaces/identity-data';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DebtService {
|
||||||
|
constructor(private readonly prisma: PrismaService, private readonly syncIdentityHandler: SyncIdentityHandler) {}
|
||||||
|
|
||||||
|
// User sync for Telegram mapping using SyncIdentityHandler
|
||||||
|
async syncUser(telegramUserId: number | string, displayName?: string) {
|
||||||
|
if (!telegramUserId) throw new BadRequestException('telegramUserId required');
|
||||||
|
const sub = `telegram:${String(telegramUserId)}`;
|
||||||
|
const identity = new IdentityData(sub, displayName || undefined, undefined, { provider: 'telegram', telegramUserId: String(telegramUserId) });
|
||||||
|
const user = await this.syncIdentityHandler.execute(identity as any);
|
||||||
|
// UserData has username/email; return display-friendly name as username
|
||||||
|
return { id: user.id, username: user.username, name: user.username };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group operations
|
||||||
|
async createGroup(ownerUserId: string, name: string) {
|
||||||
|
if (!name) throw new BadRequestException('name required');
|
||||||
|
const publicId = await this.generatePublicId();
|
||||||
|
const group = await this.prisma.debtGroup.create({
|
||||||
|
data: { name, ownerId: ownerUserId, publicId },
|
||||||
|
});
|
||||||
|
// add owner as member
|
||||||
|
await this.prisma.debtGroupMember.create({ data: { groupId: group.id, userId: ownerUserId } });
|
||||||
|
return { id: group.id, publicId: group.publicId, name: group.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGroupsForUser(userId: string) {
|
||||||
|
const memberships = await this.prisma.debtGroupMember.findMany({ where: { userId }, include: { group: true } });
|
||||||
|
return memberships.map(m => ({ id: m.group.id, publicId: m.group.publicId, name: m.group.name, ownerId: m.group.ownerId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupDetail(userId: string, id: string) {
|
||||||
|
const group = await this.prisma.debtGroup.findUnique({ where: { id }, include: { members: true, people: true } });
|
||||||
|
if (!group) throw new NotFoundException('Group not found');
|
||||||
|
// check membership
|
||||||
|
const isMember = await this.prisma.debtGroupMember.findUnique({ where: { groupId_userId: { groupId: id, userId } } });
|
||||||
|
if (!isMember) throw new ForbiddenException('Not a member');
|
||||||
|
return { id: group.id, publicId: group.publicId, name: group.name, people: group.people.map(p => ({ id: p.id, name: p.name })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinGroupByPublicId(userId: string, publicId: string) {
|
||||||
|
const group = await this.prisma.debtGroup.findUnique({ where: { publicId } });
|
||||||
|
if (!group) throw new NotFoundException('Group not found');
|
||||||
|
try {
|
||||||
|
const member = await this.prisma.debtGroupMember.create({ data: { groupId: group.id, userId } });
|
||||||
|
return { id: member.id };
|
||||||
|
} catch (e) {
|
||||||
|
// unique constraint -> already member
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async leaveGroup(userId: string, groupId: string) {
|
||||||
|
const group = await this.prisma.debtGroup.findUnique({ where: { id: groupId } });
|
||||||
|
if (!group) throw new NotFoundException('Group not found');
|
||||||
|
await this.prisma.debtGroupMember.deleteMany({ where: { groupId, userId } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Person operations (owner only)
|
||||||
|
async addPerson(userId: string, groupId: string, name: string) {
|
||||||
|
const group = await this.prisma.debtGroup.findUnique({ where: { id: groupId } });
|
||||||
|
if (!group) throw new NotFoundException('Group not found');
|
||||||
|
if (group.ownerId !== userId) throw new ForbiddenException('Only owner can add person');
|
||||||
|
// unique name per group
|
||||||
|
try {
|
||||||
|
const person = await this.prisma.debtPerson.create({ data: { groupId, name } });
|
||||||
|
return { id: person.id, name: person.name };
|
||||||
|
} catch (e) {
|
||||||
|
throw new BadRequestException('Person with same name already exists in group');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPeople(userId: string, groupId: string) {
|
||||||
|
const member = await this.prisma.debtGroupMember.findUnique({ where: { groupId_userId: { groupId, userId } } });
|
||||||
|
if (!member) throw new ForbiddenException('Not a member');
|
||||||
|
const people = await this.prisma.debtPerson.findMany({ where: { groupId, isDeleted: false } });
|
||||||
|
return people.map(p => ({ id: p.id, name: p.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePerson(userId: string, groupId: string, personId: string) {
|
||||||
|
const group = await this.prisma.debtGroup.findUnique({ where: { id: groupId } });
|
||||||
|
if (!group) throw new NotFoundException('Group not found');
|
||||||
|
if (group.ownerId !== userId) throw new ForbiddenException('Only owner can remove person');
|
||||||
|
// soft delete
|
||||||
|
await this.prisma.debtPerson.update({ where: { id: personId }, data: { isDeleted: true } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction creation
|
||||||
|
private parseAmount(raw: string | number) {
|
||||||
|
if (typeof raw === 'number') return BigInt(raw);
|
||||||
|
if (typeof raw !== 'string') throw new BadRequestException('Nominal tidak valid. Gunakan format seperti 17000 atau 17.000.');
|
||||||
|
const normalized = raw.replace(/\./g, '');
|
||||||
|
if (!/^\d+$/.test(normalized)) throw new BadRequestException('Nominal tidak valid. Gunakan format seperti 17000 atau 17.000.');
|
||||||
|
const value = BigInt(normalized);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDate(dateStr?: string) {
|
||||||
|
const tz = 'Asia/Jakarta';
|
||||||
|
if (!dateStr) {
|
||||||
|
// current date in Asia/Jakarta
|
||||||
|
const now = new Date();
|
||||||
|
const local = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Jakarta' }));
|
||||||
|
// keep date part only
|
||||||
|
local.setHours(0, 0, 0, 0);
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
// format yyyy/mm/dd
|
||||||
|
if (!/^\d{4}\/\d{2}\/\d{2}$/.test(dateStr)) throw new BadRequestException('Tanggal tidak valid. Gunakan format yyyy/mm/dd');
|
||||||
|
const [y, m, d] = dateStr.split('/').map(s => parseInt(s, 10));
|
||||||
|
// create Date in Asia/Jakarta by constructing as if local time then setHours 0
|
||||||
|
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||||
|
// store as date-only; prisma maps to date
|
||||||
|
return dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTransaction(userId: string, groupId: string, type: 'DEBT' | 'PAYMENT', body: any) {
|
||||||
|
const { from, to, price, description, date, requestId } = body;
|
||||||
|
if (!from || !to) throw new BadRequestException('from and to required');
|
||||||
|
if (from === to) throw new BadRequestException('from and to cannot be same');
|
||||||
|
// validate group exists and member
|
||||||
|
const group = await this.prisma.debtGroup.findUnique({ where: { id: groupId } });
|
||||||
|
if (!group) throw new NotFoundException('Group not found');
|
||||||
|
const membership = await this.prisma.debtGroupMember.findUnique({ where: { groupId_userId: { groupId, userId } } });
|
||||||
|
if (!membership) throw new ForbiddenException('Not a member');
|
||||||
|
|
||||||
|
// find persons by name within group
|
||||||
|
const fromPerson = await this.prisma.debtPerson.findFirst({ where: { groupId, name: from, isDeleted: false } });
|
||||||
|
const toPerson = await this.prisma.debtPerson.findFirst({ where: { groupId, name: to, isDeleted: false } });
|
||||||
|
if (!fromPerson || !toPerson) throw new BadRequestException('Person not found in group');
|
||||||
|
|
||||||
|
if (!requestId) throw new BadRequestException('requestId is required for idempotency');
|
||||||
|
|
||||||
|
// idempotency: check existing in DB
|
||||||
|
const existing = await this.prisma.debtTransaction.findFirst({ where: { groupId, requestId } });
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const amount = this.parseAmount(price);
|
||||||
|
if (amount <= 0n) throw new BadRequestException('Nominal harus lebih besar dari 0');
|
||||||
|
const txDate = this.parseDate(date);
|
||||||
|
|
||||||
|
if (type === 'DEBT') {
|
||||||
|
const created = await this.prisma.debtTransaction.create({
|
||||||
|
data: {
|
||||||
|
groupId,
|
||||||
|
fromPersonId: fromPerson.id,
|
||||||
|
toPersonId: toPerson.id,
|
||||||
|
amount,
|
||||||
|
type,
|
||||||
|
description,
|
||||||
|
transactionDate: txDate,
|
||||||
|
createdByUserId: userId,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAYMENT handling with overpayment -> create reverse debt for remainder
|
||||||
|
if (type === 'PAYMENT') {
|
||||||
|
// compute current net (positive means from owes to)
|
||||||
|
const txs = await this.prisma.debtTransaction.findMany({ where: { groupId, OR: [{ fromPersonId: fromPerson.id, toPersonId: toPerson.id }, { fromPersonId: toPerson.id, toPersonId: fromPerson.id }] } });
|
||||||
|
let net = 0n;
|
||||||
|
for (const t of txs) {
|
||||||
|
if (t.fromPersonId === fromPerson.id && t.toPersonId === toPerson.id) {
|
||||||
|
net += (t.type === 'DEBT' ? BigInt(t.amount) : -BigInt(t.amount));
|
||||||
|
} else if (t.fromPersonId === toPerson.id && t.toPersonId === fromPerson.id) {
|
||||||
|
net += (t.type === 'DEBT' ? -BigInt(t.amount) : BigInt(t.amount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// create PAYMENT transaction
|
||||||
|
const payment = await this.prisma.debtTransaction.create({
|
||||||
|
data: {
|
||||||
|
groupId,
|
||||||
|
fromPersonId: fromPerson.id,
|
||||||
|
toPersonId: toPerson.id,
|
||||||
|
amount,
|
||||||
|
type,
|
||||||
|
description,
|
||||||
|
transactionDate: txDate,
|
||||||
|
createdByUserId: userId,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// existing positive debt (from owes to)
|
||||||
|
const existingPositive = net > 0n ? net : 0n;
|
||||||
|
const remainder = amount - existingPositive;
|
||||||
|
|
||||||
|
if (remainder > 0n) {
|
||||||
|
// need creator name for description
|
||||||
|
const creator = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
const creatorName = creator?.name || creator?.username || userId;
|
||||||
|
const rev = await this.prisma.debtTransaction.create({
|
||||||
|
data: {
|
||||||
|
groupId,
|
||||||
|
fromPersonId: toPerson.id,
|
||||||
|
toPersonId: fromPerson.id,
|
||||||
|
amount: remainder,
|
||||||
|
type: 'DEBT',
|
||||||
|
description: `Sisa pembayaran hutang dari ${creatorName}`,
|
||||||
|
transactionDate: txDate,
|
||||||
|
createdByUserId: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { payment, remainder: rev };
|
||||||
|
}
|
||||||
|
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException('Invalid transaction type');
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTransactions(userId: string, groupId: string, page = 1, limit = 100) {
|
||||||
|
const membership = await this.prisma.debtGroupMember.findUnique({ where: { groupId_userId: { groupId, userId } } });
|
||||||
|
if (!membership) throw new ForbiddenException('Not a member');
|
||||||
|
const txs = await this.prisma.debtTransaction.findMany({ where: { groupId }, orderBy: [{ transactionDate: 'asc' }, { createdAt: 'asc' }], include: { fromPerson: true, toPerson: true, createdByUser: true } });
|
||||||
|
return txs.map(t => ({ id: t.id, from: t.fromPerson.name, to: t.toPerson.name, amount: t.amount.toString(), type: t.type, description: t.description, transactionDate: t.transactionDate, createdAt: t.createdAt }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDetail(userId: string, groupId: string) {
|
||||||
|
// history same as listTransactions
|
||||||
|
return this.listTransactions(userId, groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSummary(userId: string, groupId: string) {
|
||||||
|
const membership = await this.prisma.debtGroupMember.findUnique({ where: { groupId_userId: { groupId, userId } } });
|
||||||
|
if (!membership) throw new ForbiddenException('Not a member');
|
||||||
|
const people = await this.prisma.debtPerson.findMany({ where: { groupId, isDeleted: false } });
|
||||||
|
const personIds = people.map(p => p.id);
|
||||||
|
const txs = await this.prisma.debtTransaction.findMany({ where: { groupId } });
|
||||||
|
// compute pairwise net: map fromId->toId -> bigint
|
||||||
|
const pairMap = new Map<string, bigint>();
|
||||||
|
const nameById = new Map<string, string>();
|
||||||
|
people.forEach(p => nameById.set(p.id, p.name));
|
||||||
|
|
||||||
|
for (const t of txs) {
|
||||||
|
const key = `${t.fromPersonId}::${t.toPersonId}`;
|
||||||
|
const prev = pairMap.get(key) || 0n;
|
||||||
|
if (t.type === 'DEBT') {
|
||||||
|
pairMap.set(key, prev + BigInt(t.amount));
|
||||||
|
} else {
|
||||||
|
pairMap.set(key, prev - BigInt(t.amount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collapse pairwise into net per pair (from->to positive means from owes to)
|
||||||
|
const netMap = new Map<string, bigint>();
|
||||||
|
for (const [key, val] of pairMap.entries()) {
|
||||||
|
const [a, b] = key.split('::');
|
||||||
|
const reverseKey = `${b}::${a}`;
|
||||||
|
const rev = pairMap.get(reverseKey) || 0n;
|
||||||
|
const net = (val - rev);
|
||||||
|
if (net === 0n) continue;
|
||||||
|
if (net > 0n) {
|
||||||
|
netMap.set(`${a}::${b}`, net);
|
||||||
|
} else {
|
||||||
|
netMap.set(`${b}::${a}`, -net);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now perform global netting using net balances
|
||||||
|
const balance = new Map<string, bigint>();
|
||||||
|
for (const p of people) balance.set(p.id, 0n);
|
||||||
|
|
||||||
|
for (const [k, v] of netMap.entries()) {
|
||||||
|
const [a, b] = k.split('::');
|
||||||
|
balance.set(a, (balance.get(a) || 0n) - v);
|
||||||
|
balance.set(b, (balance.get(b) || 0n) + v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Greedy settlement
|
||||||
|
const settlements: Array<{ from: string; to: string; amount: string }> = [];
|
||||||
|
const creditors = [] as Array<{ id: string; bal: bigint }>;
|
||||||
|
const debtors = [] as Array<{ id: string; bal: bigint }>;
|
||||||
|
balance.forEach((v, k) => {
|
||||||
|
if (v > 0n) creditors.push({ id: k, bal: v });
|
||||||
|
else if (v < 0n) debtors.push({ id: k, bal: -v });
|
||||||
|
});
|
||||||
|
creditors.sort((a, b) => Number(b.bal - a.bal));
|
||||||
|
debtors.sort((a, b) => Number(b.bal - a.bal));
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < debtors.length && j < creditors.length) {
|
||||||
|
const debtor = debtors[i];
|
||||||
|
const creditor = creditors[j];
|
||||||
|
const settle = debtor.bal < creditor.bal ? debtor.bal : creditor.bal;
|
||||||
|
settlements.push({ from: nameById.get(debtor.id) || debtor.id, to: nameById.get(creditor.id) || creditor.id, amount: settle.toString() });
|
||||||
|
debtor.bal -= settle;
|
||||||
|
creditor.bal -= settle;
|
||||||
|
if (debtor.bal === 0n) i++;
|
||||||
|
if (creditor.bal === 0n) j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settlements;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generatePublicId(): Promise<string> {
|
||||||
|
// RL-XXXXXX style
|
||||||
|
const code = 'RL-' + randomBytes(4).toString('hex').toUpperCase().slice(0, 6);
|
||||||
|
// ensure uniqueness
|
||||||
|
const exists = await this.prisma.debtGroup.findUnique({ where: { publicId: code } });
|
||||||
|
if (exists) return this.generatePublicId();
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../shared/prisma.service';
|
||||||
|
import { JwtAuthGuard } from '../../core/auth/guards/jwt-auth.guard';
|
||||||
|
import { DebtController } from './presentation/debt.controller';
|
||||||
|
import { DebtService } from './application/debt.service';
|
||||||
|
import { IdentityModule } from '../identity/identity.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [IdentityModule],
|
||||||
|
controllers: [DebtController],
|
||||||
|
providers: [PrismaService, DebtService, JwtAuthGuard],
|
||||||
|
exports: [DebtService],
|
||||||
|
})
|
||||||
|
export class DebtModule {}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Controller, Post, Body, UseGuards, Req, Get, Param, Patch, Delete, Query } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../../core/auth/guards/jwt-auth.guard';
|
||||||
|
import { CurrentUserGuard } from '../../identity/presentation/guards/current-user.guard';
|
||||||
|
import { DebtService } from '../application/debt.service';
|
||||||
|
import { SyncUserDto } from './dto/sync-user.dto';
|
||||||
|
import { CreateGroupDto } from './dto/create-group.dto';
|
||||||
|
import { CreatePersonDto } from './dto/create-person.dto';
|
||||||
|
import { CreateTransactionDto } from './dto/create-transaction.dto';
|
||||||
|
|
||||||
|
@Controller('api/bot/debt')
|
||||||
|
@UseGuards(JwtAuthGuard, CurrentUserGuard)
|
||||||
|
export class DebtController {
|
||||||
|
constructor(private readonly debtService: DebtService) {}
|
||||||
|
|
||||||
|
@Post('users/sync')
|
||||||
|
async syncUser(@Body() body: SyncUserDto) {
|
||||||
|
const { telegramUserId, displayName } = body;
|
||||||
|
const user = await this.debtService.syncUser(telegramUserId, displayName);
|
||||||
|
return { success: true, data: user };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('groups')
|
||||||
|
async createGroup(@Req() req: any, @Body() body: CreateGroupDto) {
|
||||||
|
const userId = req.currentUser.id;
|
||||||
|
const { name } = body;
|
||||||
|
const group = await this.debtService.createGroup(userId, name);
|
||||||
|
return { success: true, data: group };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('groups')
|
||||||
|
async listGroups(@Req() req: any) {
|
||||||
|
const userId = req.currentUser.id;
|
||||||
|
const groups = await this.debtService.listGroupsForUser(userId);
|
||||||
|
return { success: true, data: groups };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('groups/:id')
|
||||||
|
async getGroup(@Req() req: any, @Param('id') id: string) {
|
||||||
|
const userId = req.currentUser.id;
|
||||||
|
const group = await this.debtService.getGroupDetail(userId, id);
|
||||||
|
return { success: true, data: group };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('groups/:publicId/join')
|
||||||
|
async joinGroup(@Req() req: any, @Param('publicId') publicId: string) {
|
||||||
|
const userId = req.currentUser.id;
|
||||||
|
const res = await this.debtService.joinGroupByPublicId(userId, publicId);
|
||||||
|
return { success: true, data: res };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('groups/:id/leave')
|
||||||
|
async leaveGroup(@Req() req: any, @Param('id') id: string) {
|
||||||
|
const userId = req.currentUser.id;
|
||||||
|
const res = await this.debtService.leaveGroup(userId, id);
|
||||||
|
return { success: true, data: res };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('groups/:id/people')
|
||||||
|
async addPerson(@Req() req: any, @Param('id') id: string, @Body() body: CreatePersonDto) {
|
||||||
|
const userId = req.currentUser.id;
|
||||||
|
const { name } = body;
|
||||||
|
const person = await this.debtService.addPerson(userId, id, name);
|
||||||
|
return { success: true, data: person };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('groups/:id/people')
|
||||||
|
async listPeople(@Req() req: any, @Param('id') id: string) {
|
||||||
|
const userId = req.currentUser.id;
|
||||||
|
const people = await this.debtService.listPeople(userId, id);
|
||||||
|
return { success: true, data: people };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('groups/:id/people/:personId')
|
||||||
|
async removePerson(@Req() req: any, @Param('id') id: string, @Param('personId') personId: string) {
|
||||||
|
const userId = req.currentUser.id;
|
||||||
|
await this.debtService.removePerson(userId, id, personId);
|
||||||
|
return { success: true, data: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('groups/:id/transactions/debt')
|
||||||
|
async addDebt(@Req() req: any, @Param('id') id: string, @Body() body: CreateTransactionDto) {
|
||||||
|
const userId = req.currentUser.id;
|
||||||
|
const res = await this.debtService.createTransaction(userId, id, 'DEBT', body);
|
||||||
|
return { success: true, data: res };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('groups/:id/transactions/payment')
|
||||||
|
async addPayment(@Req() req: any, @Param('id') id: string, @Body() body: CreateTransactionDto) {
|
||||||
|
const userId = req.currentUser.id;
|
||||||
|
const res = await this.debtService.createTransaction(userId, id, 'PAYMENT', body);
|
||||||
|
return { success: true, data: res };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('groups/:id/transactions')
|
||||||
|
async listTransactions(@Req() req: any, @Param('id') id: string, @Query('page') page = 1, @Query('limit') limit = 100) {
|
||||||
|
const userId = req.currentUser.id;
|
||||||
|
const res = await this.debtService.listTransactions(userId, id, Number(page), Number(limit));
|
||||||
|
return { success: true, data: res };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('groups/:id/summary')
|
||||||
|
async summary(@Req() req: any, @Param('id') id: string) {
|
||||||
|
const userId = req.currentUser.id;
|
||||||
|
const res = await this.debtService.getSummary(userId, id);
|
||||||
|
return { success: true, data: res };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('groups/:id/detail')
|
||||||
|
async detail(@Req() req: any, @Param('id') id: string) {
|
||||||
|
const userId = req.currentUser.id;
|
||||||
|
const res = await this.debtService.getDetail(userId, id);
|
||||||
|
return { success: true, data: res };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateGroupDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreatePersonDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { IsNotEmpty, IsString, Matches, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateTransactionDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
from!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
to!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
// Accept either plain digits or dot separated thousands: e.g. 17000 or 17.000
|
||||||
|
@Matches(/^(?:\d+|\d{1,3}(?:\.\d{3})+)$/)
|
||||||
|
price!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
// yyyy/mm/dd
|
||||||
|
@Matches(/^\d{4}\/\d{2}\/\d{2}$/)
|
||||||
|
date?: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
requestId!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class SyncUserDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
telegramUserId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { DebtService } from '../../src/modules/bot-debt/application/debt.service';
|
||||||
|
|
||||||
|
describe('DebtService summary/netting', () => {
|
||||||
|
let service: DebtService;
|
||||||
|
let prisma: any;
|
||||||
|
let mockSync: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
prisma = {
|
||||||
|
debtGroupMember: { findUnique: jest.fn().mockResolvedValue({}) },
|
||||||
|
debtPerson: { findMany: jest.fn() },
|
||||||
|
debtTransaction: { findMany: jest.fn() },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockSync = { execute: jest.fn().mockResolvedValue({ id: 'u' }) };
|
||||||
|
|
||||||
|
service = new DebtService(prisma as any, mockSync as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
function peopleABC() {
|
||||||
|
return [
|
||||||
|
{ id: 'A', name: 'A' },
|
||||||
|
{ id: 'B', name: 'B' },
|
||||||
|
{ id: 'C', name: 'C' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('basic debt A->B 17000', async () => {
|
||||||
|
prisma.debtPerson.findMany.mockResolvedValue(peopleABC());
|
||||||
|
prisma.debtTransaction.findMany.mockResolvedValue([
|
||||||
|
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(17000), type: 'DEBT' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await service.getSummary('u', 'g');
|
||||||
|
expect(res).toEqual([{ from: 'A', to: 'B', amount: '17000' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulation A->B 17k + 10k = 27k', async () => {
|
||||||
|
prisma.debtPerson.findMany.mockResolvedValue(peopleABC());
|
||||||
|
prisma.debtTransaction.findMany.mockResolvedValue([
|
||||||
|
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(17000), type: 'DEBT' },
|
||||||
|
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(10000), type: 'DEBT' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await service.getSummary('u', 'g');
|
||||||
|
expect(res).toEqual([{ from: 'A', to: 'B', amount: '27000' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reverse debt A->B 27k B->A 5k = A->B 22k', async () => {
|
||||||
|
prisma.debtPerson.findMany.mockResolvedValue(peopleABC());
|
||||||
|
prisma.debtTransaction.findMany.mockResolvedValue([
|
||||||
|
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(27000), type: 'DEBT' },
|
||||||
|
{ fromPersonId: 'B', toPersonId: 'A', amount: BigInt(5000), type: 'DEBT' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await service.getSummary('u', 'g');
|
||||||
|
expect(res).toEqual([{ from: 'A', to: 'B', amount: '22000' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('partial payment A->B 50k debt, payment 20k => A->B 30k', async () => {
|
||||||
|
prisma.debtPerson.findMany.mockResolvedValue(peopleABC());
|
||||||
|
prisma.debtTransaction.findMany.mockResolvedValue([
|
||||||
|
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(50000), type: 'DEBT' },
|
||||||
|
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(20000), type: 'PAYMENT' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await service.getSummary('u', 'g');
|
||||||
|
expect(res).toEqual([{ from: 'A', to: 'B', amount: '30000' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overpayment A->B 50k debt, payment 60k => B->A 10k', async () => {
|
||||||
|
prisma.debtPerson.findMany.mockResolvedValue(peopleABC());
|
||||||
|
prisma.debtTransaction.findMany.mockResolvedValue([
|
||||||
|
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(50000), type: 'DEBT' },
|
||||||
|
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(60000), type: 'PAYMENT' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await service.getSummary('u', 'g');
|
||||||
|
expect(res).toEqual([{ from: 'B', to: 'A', amount: '10000' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cross substitution A->B 100k B->C 100k C->A 100k => empty', async () => {
|
||||||
|
prisma.debtPerson.findMany.mockResolvedValue(peopleABC());
|
||||||
|
prisma.debtTransaction.findMany.mockResolvedValue([
|
||||||
|
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(100000), type: 'DEBT' },
|
||||||
|
{ fromPersonId: 'B', toPersonId: 'C', amount: BigInt(100000), type: 'DEBT' },
|
||||||
|
{ fromPersonId: 'C', toPersonId: 'A', amount: BigInt(100000), type: 'DEBT' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await service.getSummary('u', 'g');
|
||||||
|
expect(res).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cross substitution partial A->B 100k B->C 50k => settlement equivalent', async () => {
|
||||||
|
prisma.debtPerson.findMany.mockResolvedValue(peopleABC());
|
||||||
|
prisma.debtTransaction.findMany.mockResolvedValue([
|
||||||
|
{ fromPersonId: 'A', toPersonId: 'B', amount: BigInt(100000), type: 'DEBT' },
|
||||||
|
{ fromPersonId: 'B', toPersonId: 'C', amount: BigInt(50000), type: 'DEBT' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await service.getSummary('u', 'g');
|
||||||
|
// possible valid settlements: A->B 50000, A->C 50000 or other equivalent
|
||||||
|
expect(res.reduce((acc: any, cur: any) => acc + cur.amount, '')).toBeDefined();
|
||||||
|
expect(res.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user