{"openapi":"3.0.3","info":{"title":"MonieRemit API","version":"1.0.0","description":"MonieRemit custodial savings and disbursement platform"},"servers":[{"url":"http://localhost:4000","description":"Local development"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"},"cookieAuth":{"type":"apiKey","in":"cookie","name":"app_token"}}},"tags":[{"name":"Auth","description":"Customer registration and authentication"},{"name":"Customers","description":"Customer profile and account management"},{"name":"Disbursements","description":"Disbursement plans and history"},{"name":"Admin","description":"Internal admin operations"},{"name":"OAuth","description":"Social authentication"},{"name":"Users","description":"Authenticated user profile"}],"paths":{"/api/v1/admin/auth/sign-in":{"post":{"tags":["Admin"],"summary":"Sign in as staff (superadmin, admin, support or officer role)","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"}}}}}},"responses":{"200":{"description":"{ token, admin }. Token lasts 8 hours."},"401":{"description":"Invalid credentials"},"429":{"description":"Too many failed attempts"}}}},"/api/v1/admin/auth/links/{token}":{"get":{"tags":["Admin"],"summary":"Check an emailed invite or password reset link","responses":{"200":{"description":"{ purpose, email, firstName, expiresAt }"},"410":{"description":"Link expired or already used"}}},"post":{"tags":["Admin"],"summary":"Set a password with an emailed link, then sign in","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["password"],"properties":{"password":{"type":"string"}}}}}},"responses":{"200":{"description":"{ token, admin }"},"410":{"description":"Link expired or already used"}}}},"/api/v1/admin/auth/me":{"get":{"tags":["Admin"],"summary":"Current admin, role and permissions","security":[{"bearerAuth":[]}]}},"/api/v1/admin/overview":{"get":{"tags":["Admin"],"summary":"Dashboard metrics and daily series","security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"days","schema":{"type":"integer","default":30,"minimum":7,"maximum":365}}]}},"/api/v1/admin/customers":{"get":{"tags":["Admin"],"summary":"Search and filter customers","security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"q","schema":{"type":"string"},"description":"Name, email, phone, account number, referral code or id"},{"in":"query","name":"status","schema":{"type":"string"}},{"in":"query","name":"kyc","schema":{"type":"string"}},{"in":"query","name":"officer","schema":{"type":"string"}},{"in":"query","name":"sort","schema":{"type":"string","enum":["newest","oldest","balance_desc","balance_asc"]}},{"in":"query","name":"page","schema":{"type":"integer"}},{"in":"query","name":"pageSize","schema":{"type":"integer"}}]}},"/api/v1/admin/customers/{id}/freeze":{"post":{"tags":["Admin"],"summary":"Freeze the customer's Qore account and suspend them (reason required)","security":[{"bearerAuth":[]}]}},"/api/v1/admin/deletions":{"get":{"tags":["Admin"],"summary":"Customer account deletions awaiting review (or reviewed), with what was tied to each user","security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"status","schema":{"type":"string","enum":["pending_review","reviewed"],"default":"pending_review"}}]}},"/api/v1/admin/transactions":{"get":{"tags":["Admin"],"summary":"Search transactions across all customers","security":[{"bearerAuth":[]}]}},"/api/v1/admin/transactions/{id}/requery":{"post":{"tags":["Admin"],"summary":"Ask Qore for the transaction's status (read-only, changes nothing)","security":[{"bearerAuth":[]}]}},"/api/v1/admin/officers/{id}/send-link":{"post":{"tags":["Admin"],"summary":"Email an officer a link to sign in to the admin app","description":"Officers without a sign-in account need firstName, lastName and email in the body. Anyone else gets their invite again, or a password reset.\n","security":[{"bearerAuth":[]}]}},"/api/v1/admin/admins":{"post":{"tags":["Admin"],"summary":"Invite an admin (superadmin only). Emails them a link to set their password.","security":[{"bearerAuth":[]}]}},"/api/v1/admin/settings/{key}":{"patch":{"tags":["Admin"],"summary":"Change an app setting such as a fee or limit (superadmin only)","security":[{"bearerAuth":[]}]}},"/api/v1/auth/register":{"post":{"tags":["Auth"],"summary":"Step 1 — Register a new customer","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string","format":"email","example":"user@example.com"},"password":{"type":"string","minLength":8}}}}}},"responses":{"201":{"description":"Customer registered. Returns full account and customer fields (password and PIN hash omitted). Use the returned `id` (accountId) in the next step (otp/email/send / otp/email/verify)."},"400":{"description":"Validation error or phone/email already registered"}}}},"/api/v1/auth/otp/email/send":{"post":{"tags":["Auth"],"summary":"Step 2a — Send an OTP to the account's email address","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId"],"properties":{"accountId":{"type":"string","format":"uuid"}}}}}},"responses":{"200":{"description":"OTP sent to the account's email address."},"400":{"description":"Validation error, account not found, or no email on file"}}}},"/api/v1/auth/otp/email/verify":{"post":{"tags":["Auth"],"summary":"Step 2b — Verify the email OTP, marking emailVerified","description":"Returns access + refresh tokens. customerStatus becomes \"verified\" once the email is confirmed. No otp id is needed — the code is matched against the most recent unexpired, unused OTP sent to this account's email.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","pin"],"properties":{"accountId":{"type":"string","format":"uuid"},"pin":{"type":"string","minLength":6,"maxLength":6}}}}}},"responses":{"200":{"description":"OTP verified. Returns accessToken and refreshToken."},"400":{"description":"Incorrect or expired OTP"}}}},"/api/v1/auth/otp/phone/send":{"post":{"tags":["Auth"],"summary":"Step 3a — Send a real SMS OTP to the account's phone number (via Termii)","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId"],"properties":{"accountId":{"type":"string","format":"uuid"}}}}}},"responses":{"200":{"description":"OTP sent via SMS."},"400":{"description":"Validation error, account not found, or no phone on file"}}}},"/api/v1/auth/otp/phone/verify":{"post":{"tags":["Auth"],"summary":"Step 3b — Verify the phone OTP, marking phoneVerified","description":"Returns access + refresh tokens. If emailVerified is already true, customerStatus becomes \"verified\". No otp id is needed from the client — the server resolves the most recent pending phone OTP for this account and verifies the code with Termii.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","pin"],"properties":{"accountId":{"type":"string","format":"uuid"},"pin":{"type":"string","minLength":6,"maxLength":6}}}}}},"responses":{"200":{"description":"OTP verified. Returns accessToken and refreshToken."},"400":{"description":"Incorrect or expired OTP"}}}},"/api/v1/auth/verify-bvn":{"post":{"tags":["Auth"],"summary":"Step 4 — Verify BVN (requires authentication)","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["bvn","firstName","lastName","phone","address","gender"],"properties":{"bvn":{"type":"string","minLength":11,"maxLength":11,"example":"22216142222"},"firstName":{"type":"string"},"lastName":{"type":"string"},"phone":{"type":"string","example":"08012345678"},"address":{"type":"string"},"gender":{"type":"string","enum":["male","female"]}}}}}},"responses":{"200":{"description":"BVN verified. Returns firstName, lastName, and phone from BVN registry."},"400":{"description":"Duplicate BVN or already verified"},"401":{"description":"Unauthenticated"}}}},"/api/v1/auth/login":{"post":{"tags":["Auth"],"summary":"Log in with phone or email, plus password","description":"Provide either `phone` or `email` (not both required) along with `password`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["password"],"properties":{"phone":{"type":"string","example":"08012345678"},"email":{"type":"string","example":"jane@example.com"},"password":{"type":"string"}}}}}},"responses":{"200":{"description":"Returns accessToken and refreshToken."},"400":{"description":"Validation error"},"401":{"description":"Invalid credentials"}}}},"/api/v1/auth/refresh-token":{"post":{"tags":["Auth"],"summary":"Exchange a customer refresh token for a new token pair","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["refreshToken"],"properties":{"refreshToken":{"type":"string"}}}}}},"responses":{"200":{"description":"Returns new accessToken and refreshToken."},"400":{"description":"Validation error"},"401":{"description":"Refresh token expired or invalid"}}}},"/api/v1/auth/token":{"post":{"tags":["Auth"],"summary":"Exchange an account refresh token for a new access token","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["refreshToken"],"properties":{"refreshToken":{"type":"string"}}}}}},"responses":{"200":{"description":"Returns new accessToken."},"401":{"description":"Invalid or missing refresh token"}}}},"/api/v1/auth/check-email":{"post":{"tags":["Auth"],"summary":"Check if an email address is already registered","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email"}}}}}},"responses":{"200":{"description":"Email is available"},"400":{"description":"Email already registered or missing"}}}},"/api/v1/auth/forgot-password":{"post":{"tags":["Auth"],"summary":"Request a password reset OTP via email","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email"}}}}}},"responses":{"200":{"description":"Reset OTP sent if account exists"},"400":{"description":"Missing email or account not found"}}}},"/api/v1/auth/reset-password":{"post":{"tags":["Auth"],"summary":"Reset password using an OTP","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","code","password"],"properties":{"email":{"type":"string","format":"email"},"code":{"type":"string","example":"482910"},"password":{"type":"string","format":"password"}}}}}},"responses":{"200":{"description":"Password reset successfully"},"400":{"description":"Missing fields or same password reused"},"401":{"description":"Invalid OTP"}}}},"/api/v1/auth/set-password":{"post":{"tags":["Auth"],"summary":"Set a password for a social-auth account that has no password yet","description":"Only succeeds if the account was created via OAuth (Google etc.) and has no password. Use change-password instead for accounts that already have one.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["password"],"properties":{"password":{"type":"string","format":"password","minLength":8}}}}}},"responses":{"200":{"description":"Password set successfully"},"400":{"description":"Missing password"},"401":{"description":"Unauthorized"},"409":{"description":"A password is already set on this account"}}}},"/api/v1/auth/otp/request":{"post":{"tags":["Auth"],"summary":"Request a one-time code for a sensitive action","description":"Sends a 6-digit OTP to the account's registered email. Use the returned pinId with otpCode when calling OTP-gated endpoints.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"OTP sent. Returns { pinId }."},"400":{"description":"No email address on file"},"401":{"description":"Unauthenticated"}}}},"/api/v1/auth/pin":{"post":{"tags":["Auth"],"summary":"Set or update the transaction PIN","description":"PIN must be exactly 4 digits. If a PIN is already set, currentPin is required.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["pin"],"properties":{"pin":{"type":"string","example":"1234","description":"New 4-digit PIN"},"currentPin":{"type":"string","example":"0000","description":"Required only if a PIN is already set"}}}}}},"responses":{"200":{"description":"PIN set successfully"},"400":{"description":"Invalid PIN format or missing currentPin"},"401":{"description":"Unauthenticated or incorrect current PIN"}}}},"/api/v1/auth/pin/verify":{"post":{"tags":["Auth"],"summary":"Verify the transaction PIN for the current session","description":"Locks the PIN for 15 minutes after 5 incorrect attempts. After 3 such lockouts within a rolling 24h window, the account is frozen and requires manual (COO) intervention to unlock.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["pin"],"properties":{"pin":{"type":"string","example":"1234"}}}}}},"responses":{"200":{"description":"PIN correct"},"401":{"description":"Incorrect PIN"},"423":{"description":"PIN temporarily locked after too many failed attempts"}}}},"/api/v1/auth/pin/reset":{"post":{"tags":["Auth"],"summary":"Reset the transaction PIN using an OTP (no current PIN required)","description":"For customers who have forgotten their PIN. Call POST /auth/otp/request first to get a pinId.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["pin","otpPinId","otpCode"],"properties":{"pin":{"type":"string","example":"1234","description":"New 4-digit PIN"},"otpPinId":{"type":"string"},"otpCode":{"type":"string","example":"482910"}}}}}},"responses":{"200":{"description":"PIN reset successfully"},"400":{"description":"Invalid PIN format or missing fields"},"401":{"description":"Invalid or expired OTP"}}}},"/api/v1/auth/change-password":{"post":{"tags":["Auth"],"summary":"Change the customer's password (OTP required)","description":"Requires a valid OTP. Call POST /auth/otp/request first to get pinId.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["currentPassword","newPassword","otpPinId","otpCode"],"properties":{"currentPassword":{"type":"string"},"newPassword":{"type":"string","minLength":8},"otpPinId":{"type":"string"},"otpCode":{"type":"string","example":"123456"}}}}}},"responses":{"200":{"description":"Password changed successfully"},"400":{"description":"Missing fields"},"401":{"description":"Incorrect current password or invalid OTP"}}}},"/api/v1/beneficiaries/lookup":{"get":{"tags":["Beneficiaries"],"summary":"Look up an account name before saving or transferring","security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"accountNumber","required":true,"schema":{"type":"string"}},{"in":"query","name":"bankCode","required":true,"schema":{"type":"string","example":"044"}}],"responses":{"200":{"description":"Returns accountName, accountNumber, bankCode, and nipSessionId"},"400":{"description":"Account not found or invalid bank code"},"401":{"description":"Unauthenticated"}}}},"/api/v1/beneficiaries":{"post":{"tags":["Beneficiaries"],"summary":"Save a bank account as a beneficiary","description":"Run name lookup first to get the verified account name before saving.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountNumber","bankCode","bankName"],"properties":{"accountNumber":{"type":"string"},"bankCode":{"type":"string","example":"044"},"bankName":{"type":"string","example":"Access Bank"}}}}}},"responses":{"201":{"description":"Beneficiary saved with verified account name"},"400":{"description":"Account not found or already saved"},"401":{"description":"Unauthenticated"}}},"get":{"tags":["Beneficiaries"],"summary":"List saved beneficiaries (default first)","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Array of beneficiary accounts"},"401":{"description":"Unauthenticated"}}}},"/api/v1/beneficiaries/{id}/default":{"patch":{"tags":["Beneficiaries"],"summary":"Set a beneficiary as the default withdrawal account","security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Default updated"},"401":{"description":"Unauthenticated"},"404":{"description":"Beneficiary not found"}}}},"/api/v1/beneficiaries/{id}":{"delete":{"tags":["Beneficiaries"],"summary":"Remove a beneficiary (OTP required)","description":"Requires a valid OTP. Call POST /auth/otp/request first to get pinId.","security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["otpPinId","otpCode"],"properties":{"otpPinId":{"type":"string"},"otpCode":{"type":"string","example":"123456"}}}}}},"responses":{"200":{"description":"Removed. If it was the default, the next account is promoted."},"401":{"description":"OTP invalid or expired"},"404":{"description":"Beneficiary not found"}}}},"/api/v1/customers/me":{"get":{"tags":["Customers"],"summary":"Get the authenticated customer's full profile","description":"Returns all account fields, customer fields, notification settings, disbursement plan, hasPin, and live balanceKobo from Qore. Sensitive fields (password, PIN hash, BVN hash) are never returned.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Full customer profile"},"401":{"description":"Unauthenticated"}}}},"/api/v1/customers/create-bank-account":{"post":{"tags":["Customers"],"summary":"Create a Qore MFB-backed bank account for the authenticated customer","security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Account created. PND is activated immediately. Returns bankAccountNumber."},"400":{"description":"Account already exists for this customer"},"401":{"description":"Unauthenticated"}}}},"/api/v1/customers/balance":{"get":{"tags":["Customers"],"summary":"Poll current Qore account balance (live, not cached)","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Returns balanceKobo as a number."},"400":{"description":"No bank account linked yet"},"401":{"description":"Unauthenticated"}}}},"/api/v1/customers/notifications":{"patch":{"tags":["Customers"],"summary":"Update notification preferences (SMS/email toggles)","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"smsEnabled":{"type":"boolean"},"emailEnabled":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Updated NotificationSettings"},"400":{"description":"Neither smsEnabled nor emailEnabled provided"},"401":{"description":"Unauthenticated"}}}},"/api/v1/customers/referrals":{"get":{"tags":["Customers"],"summary":"Get the authenticated customer's referral code and referral stats","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"referralCode, totalReferred, successfulReferrals (referred customers who've made a confirmed deposit), and a list of referrals"},"401":{"description":"Unauthenticated"}}}},"/api/v1/customers/close-account":{"post":{"tags":["Customers"],"summary":"Permanently close the authenticated customer's account (OTP required)","description":"Withdraws the full vault balance to the plan's withdrawal account (or the customer's default saved bank account if no plan exists), waives the 4% early-withdrawal penalty only, freezes the Qore account, and marks the customer closed. Irreversible without admin intervention.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["idempotencyKey","otpPinId","otpCode"],"properties":{"idempotencyKey":{"type":"string"},"otpPinId":{"type":"string"},"otpCode":{"type":"string"}}}}}},"responses":{"200":{"description":"status (confirmed | pending) and amountWithdrawnKobo"},"400":{"description":"Account not active, a transaction is still in progress, no destination bank account on file, or OTP invalid/expired"},"401":{"description":"Unauthenticated"}}}},"/api/v1/customers/delete-account":{"post":{"tags":["Customers"],"summary":"Delete the authenticated customer's account (OTP required)","description":"Signs the customer out everywhere and erases personal data we don't have to keep: saved payout accounts, spending logs, devices, preferences, password, PIN and avatar. Email, phone and BVN are freed for reuse. Transactions and KYC records are kept for 5 years. An account that still has a bank account is closed first in the same request when idempotencyKey is sent (the balance is withdrawn as in Close account); deletion continues once that withdrawal confirms. Every deletion is queued for admin review.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["otpPinId","otpCode"],"properties":{"otpPinId":{"type":"string"},"otpCode":{"type":"string"},"via":{"type":"string","enum":["mobile","web"]},"idempotencyKey":{"type":"string","description":"Needed when the account must be closed first"}}}}}},"responses":{"200":{"description":"{ status: deleted, retainUntil }, or { status: closing } while the closing withdrawal settles (nothing deleted yet)"},"400":{"description":"Closing the account failed","e.g. a transaction in progress or no bank account to send the balance to":null},"401":{"description":"Unauthenticated","or OTP invalid or expired":null},"409":{"description":"Frozen account, or it must be closed first and no idempotencyKey was sent (data.code CLOSE_ACCOUNT_FIRST)"},"500":{"description":"Deletion failed part-way; nothing was changed and admins were alerted"}}}},"/api/v1/disbursements/plan/quote":{"get":{"tags":["Disbursements"],"summary":"Preview all fees involved in running a disbursement plan at this amount","description":"Combines two figures for the given amount: depositFee (an estimate of what the customer's own bank may charge to fund their wallet — not charged or guaranteed by MonieRemit) and disbursementFee (the real NIP fee added on top of the plan amount and debited from the wallet each payout cycle; the recipient still gets the full plan amount). totalFeesKobo sums both feeKobo values for a single at-a-glance number.\n","security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"amountKobo","required":true,"schema":{"type":"integer"},"description":"Amount in kobo (100 = ₦1)"}],"responses":{"200":{"description":"Returns { depositFee, disbursementFee, totalFeesKobo }"},"400":{"description":"amountKobo is missing or invalid"},"401":{"description":"Unauthenticated"}}}},"/api/v1/disbursements/plan":{"post":{"tags":["Disbursements"],"summary":"Create or update the customer's recurring disbursement plan","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["totalAmountKobo","durationDays","frequency","withdrawalAccountNumber","withdrawalBankCode"],"properties":{"totalAmountKobo":{"type":"integer","description":"Total pot for the whole plan, in kobo"},"durationDays":{"type":"integer","description":"Total plan length in days. Per-cycle amount is derived: totalAmountKobo / number of cycles."},"frequency":{"type":"string","enum":["daily","weekly","monthly"]},"weekDay":{"type":"string","enum":["monday","tuesday","wednesday","thursday","friday","saturday","sunday"],"description":"Required when frequency is weekly. Server computes the next occurrence."},"withdrawalAccountNumber":{"type":"string"},"withdrawalBankCode":{"type":"string","example":"044"},"withdrawalAccountName":{"type":"string","description":"If omitted, verified via name enquiry automatically"},"scheduledTime":{"type":"string","example":"09:00","description":"Time of day to run (HH:MM 24h). Optional."},"scheduledTimezone":{"type":"string","example":"Africa/Lagos","description":"IANA timezone for scheduledTime. Optional, defaults to UTC."}}}}}},"responses":{"200":{"description":"Plan created or updated. amount/totalAmountKobo serialised as strings (BigInt)."},"400":{"description":"Validation error"},"401":{"description":"Unauthenticated"}}},"patch":{"tags":["Disbursements"],"summary":"Update an existing disbursement plan (OTP required)","description":"Same body as POST. Requires a valid OTP — call POST /customers/otp/request first.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["totalAmountKobo","durationDays","frequency","withdrawalAccountNumber","withdrawalBankCode","otpPinId","otpCode"],"properties":{"totalAmountKobo":{"type":"integer","description":"Total pot for the whole plan, in kobo"},"durationDays":{"type":"integer","description":"Total plan length in days"},"frequency":{"type":"string","enum":["daily","weekly","monthly"]},"weekDay":{"type":"string","enum":["monday","tuesday","wednesday","thursday","friday","saturday","sunday"]},"withdrawalAccountNumber":{"type":"string"},"withdrawalBankCode":{"type":"string","example":"044"},"withdrawalAccountName":{"type":"string"},"scheduledTime":{"type":"string","example":"09:00"},"scheduledTimezone":{"type":"string","example":"Africa/Lagos"},"otpPinId":{"type":"string"},"otpCode":{"type":"string","example":"123456"}}}}}},"responses":{"200":{"description":"Plan updated."},"400":{"description":"Validation error"},"401":{"description":"OTP invalid or expired"}}},"get":{"tags":["Disbursements"],"summary":"Fetch the customer's active disbursement plan","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Disbursement plan object. amount is serialised as a string (BigInt)."},"401":{"description":"Unauthenticated"},"404":{"description":"No disbursement plan found for this customer"}}}},"/api/v1/disbursements/history":{"get":{"tags":["Disbursements"],"summary":"Fetch the customer's disbursement execution history","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Array of disbursement Transaction objects ordered by createdAt desc. amountKobo serialised as string."},"401":{"description":"Unauthenticated"}}}},"/api/v1/disbursements/plan/borrow/quote":{"get":{"tags":["Disbursements"],"summary":"Check eligibility and preview a Borrow (advance next period's payout)","description":"No fee beyond the standard NIP fee already included in every disbursement. Ineligible when the plan isn't active, this would be its final period, the customer already borrowed or got paid this period, or the plan has hit its lifetime borrow cap — `reason` explains which.\n","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Returns { eligible, amountKobo, reason? }"},"401":{"description":"Unauthenticated"}}}},"/api/v1/disbursements/plan/borrow":{"post":{"tags":["Disbursements"],"summary":"Borrow — advance the next period's disbursement now","description":"Triggers an on-demand disbursement cycle for the plan's current per-cycle amount. Advances the plan's schedule exactly as a normal successful cycle would, which is what skips the next scheduled payout — no separate \"skip\" step needed.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["idempotencyKey"],"properties":{"idempotencyKey":{"type":"string","format":"uuid","description":"Client-generated UUID, unique per attempt. Reuse the same value when retrying a request that may not have completed."}}}}}},"responses":{"200":{"description":"Borrow initiated. Status is confirmed or pending."},"400":{"description":"Not eligible to borrow, or transfer failure"},"401":{"description":"Unauthenticated"}}}},"/api/v1/disbursements/plan/extra-cash/quote":{"get":{"tags":["Disbursements"],"summary":"Preview an Early Vault Withdrawal (\"Extra Cash\") before requesting one","description":"Returns the live wallet balance (vaultBalanceKobo), the amount reserved to cover remaining scheduled payouts including their NIP fees (reservedKobo), and the resulting ceiling on what can be withdrawn right now (maxWithdrawalKobo).\n","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Returns { vaultBalanceKobo, reservedKobo, maxWithdrawalKobo, feeRate }"},"400":{"description":"No active disbursement plan found"},"401":{"description":"Unauthenticated"}}}},"/api/v1/disbursements/plan/extra-cash":{"post":{"tags":["Disbursements"],"summary":"Withdraw Extra Cash from unused vault balance ahead of schedule","description":"Charges a flat early-withdrawal fee on top of the standard NIP fee. The amount requested is capped server-side at the current maxWithdrawalKobo from the quote endpoint — the recipient's own withdrawal account still gets the full amount requested, with fees deducted from the wallet.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["amountKobo","idempotencyKey"],"properties":{"amountKobo":{"type":"integer","description":"Amount in kobo (100 = ₦1)"},"idempotencyKey":{"type":"string","format":"uuid","description":"Client-generated UUID, unique per attempt. Reuse the same value when retrying a request that may not have completed."}}}}}},"responses":{"200":{"description":"Withdrawal initiated. Status is confirmed or pending."},"400":{"description":"Validation error, amount exceeds max withdrawal, or transfer failure"},"401":{"description":"Unauthenticated"}}}},"/api/v1/banks":{"get":{"tags":["Banks"],"summary":"List all supported banks (cached 24h, no auth required)","description":"Returns all commercial banks supported by Qore. Response is cached for 24 hours. Use bankCode when calling name lookup or initiating a transfer.","responses":{"200":{"description":"Array of { bankCode, bankName }"}}}},"/api/v1/oauth/google-sign-in":{"post":{"tags":["OAuth"],"summary":"Sign in or register with Google","description":"Send either a Google ID token (mobile app) or an authorization code (web). The ID token must be issued to GOOGLE_CLIENT_ID, GOOGLE_ANDROID_CLIENT_ID or GOOGLE_IOS_CLIENT_ID. Creates a customer account for a new email, or signs in the existing one. Staff, suspended and deleted accounts are refused.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"idToken":{"type":"string","description":"Google ID token from the mobile app's sign-in flow"},"code":{"type":"string","description":"Google authorization code from the web OAuth redirect"},"referralCode":{"type":"string","description":"Optional. Applied only when this creates a new account"}}}}}},"responses":{"200":{"description":"Signed in. Returns customer tokens and whether a PIN is set.","content":{"application/json":{"schema":{"type":"object","properties":{"accessToken":{"type":"string"},"refreshToken":{"type":"string"},"hasPin":{"type":"boolean","description":"False means send them to set a PIN first"}}}}}},"400":{"description":"Neither idToken nor code was sent"},"401":{"description":"Google rejected the token","or it wasn't issued to our client":null},"403":{"description":"Staff","suspended or deleted account":null},"500":{"description":"Internal server error"}}}},"/api/v1/spendings":{"post":{"tags":["Spendings"],"summary":"Log a spending","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["amountKobo","category","date"],"properties":{"amountKobo":{"type":"integer","example":50000,"description":"Amount in kobo"},"category":{"type":"string","example":"food"},"description":{"type":"string","example":"Lunch at work"},"date":{"type":"string","format":"date","example":"2026-06-29"}}}}}},"responses":{"201":{"description":"Spending logged"},"400":{"description":"Missing required fields"}}},"get":{"tags":["Spendings"],"summary":"List spendings (paginated)","security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"page","schema":{"type":"integer","default":1}},{"in":"query","name":"pageSize","schema":{"type":"integer","default":20}},{"in":"query","name":"category","schema":{"type":"string"}},{"in":"query","name":"from","schema":{"type":"string","format":"date","example":"2026-01-01"}},{"in":"query","name":"to","schema":{"type":"string","format":"date","example":"2026-06-30"}}],"responses":{"200":{"description":"Paginated spending list"}}}},"/api/v1/spendings/report":{"get":{"tags":["Spendings"],"summary":"Generate a spending report","description":"Returns totals and category breakdown. Use format=csv for a downloadable file.","security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"from","schema":{"type":"string","format":"date","example":"2026-01-01"}},{"in":"query","name":"to","schema":{"type":"string","format":"date","example":"2026-06-30"}},{"in":"query","name":"format","schema":{"type":"string","enum":["json","csv"],"default":"json"}}],"responses":{"200":{"description":"Spending report (JSON or CSV)"}}},"post":{"tags":["Spendings"],"summary":"Email the customer their spending breakdown for a given month","description":"Always sent to the customer's own registered account email.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["month"],"properties":{"month":{"type":"string","example":"2026-06","description":"YYYY-MM"}}}}}},"responses":{"200":{"description":"Report emailed"},"400":{"description":"Invalid month, or no email on file"}}}},"/api/v1/spendings/{id}":{"patch":{"tags":["Spendings"],"summary":"Update a spending entry","security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"amountKobo":{"type":"integer"},"category":{"type":"string"},"description":{"type":"string"},"date":{"type":"string","format":"date"}}}}}},"responses":{"200":{"description":"Updated spending"},"404":{"description":"Spending not found"}}},"delete":{"tags":["Spendings"],"summary":"Delete a spending entry","security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deleted"},"404":{"description":"Spending not found"}}}},"/api/v1/support/tickets":{"get":{"tags":["Support"],"summary":"List the customer's support tickets","security":[{"bearerAuth":[]}]},"post":{"tags":["Support"],"summary":"Open a support ticket","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["subject","message"],"properties":{"subject":{"type":"string"},"category":{"type":"string","enum":["general","deposit","disbursement","transfer","account","kyc"]},"message":{"type":"string"},"transactionId":{"type":"string","format":"uuid","description":"Optional transaction the issue is about"}}}}}}}},"/api/v1/support/tickets/{id}":{"get":{"tags":["Support"],"summary":"A ticket and its conversation (internal admin notes excluded)","security":[{"bearerAuth":[]}]}},"/api/v1/support/tickets/{id}/messages":{"post":{"tags":["Support"],"summary":"Reply on a ticket. Reopens it if it was resolved.","security":[{"bearerAuth":[]}]}},"/api/v1/transactions/deposit/sync":{"post":{"tags":["Transactions"],"summary":"Sync inbound deposits from Qore and return updated balance","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Deposits synced. Returns updated balanceKobo."},"400":{"description":"No bank account linked yet"},"401":{"description":"Unauthenticated"}}}},"/api/v1/transactions/deposit/test":{"post":{"tags":["Transactions"],"summary":"[TEST ONLY] Credit the account with a simulated deposit","description":"Not available in production (NODE_ENV=production). Credits the Qore account directly and records the transaction.","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"amountKobo":{"type":"integer","default":5000000,"description":"Amount in kobo. Defaults to 5,000,000 (NGN 50,000). Max 10,000,000 (NGN 100,000)."}}}}}},"responses":{"200":{"description":"Account credited. Returns updated balanceKobo."},"400":{"description":"No bank account linked or invalid amount"},"403":{"description":"Not available in production"}}}},"/api/v1/transactions/deposit/fee":{"get":{"tags":["Transactions"],"summary":"Estimate the fee a customer's own bank may charge to fund their wallet","description":"Estimate only. Deposits are inbound NIP transfers the customer sends from their own bank — that bank charges the fee, not MonieRemit, so this cannot be guaranteed or collected by us. Uses the same CBN NIP fee schedule as outbound transfers.\n","security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"amountKobo","required":true,"schema":{"type":"integer"},"description":"Amount in kobo (100 = ₦1)"}],"responses":{"200":{"description":"Returns fee breakdown (totalKobo, feeKobo, vatKobo, amountKobo)"},"400":{"description":"amountKobo is missing or invalid"},"401":{"description":"Unauthenticated"}}}},"/api/v1/transactions/transfer/fee":{"get":{"tags":["Transactions"],"summary":"Calculate the transfer fee for a given amount","security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"amountKobo","required":true,"schema":{"type":"integer"},"description":"Amount in kobo (100 = ₦1)"}],"responses":{"200":{"description":"Returns fee breakdown (totalKobo, feeKobo, vatKobo, amountKobo)"},"400":{"description":"amountKobo is missing or invalid"},"401":{"description":"Unauthenticated"}}}},"/api/v1/transactions/transfer":{"post":{"tags":["Transactions"],"summary":"Initiate an interbank transfer","description":"Full transfer lifecycle — deactivates PND, places lien, debits customer account, sends NIP interbank transfer, then removes lien and re-activates PND. Call name lookup first to get nipSessionId, then pass it here.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["destinationAccountNumber","destinationBankCode","destinationBankName","destinationAccountName","amountKobo","idempotencyKey"],"properties":{"destinationAccountNumber":{"type":"string"},"destinationBankCode":{"type":"string","example":"044"},"destinationBankName":{"type":"string","example":"Access Bank"},"destinationAccountName":{"type":"string","example":"JANE BOND DOE"},"amountKobo":{"type":"integer","description":"Amount in kobo (100 = ₦1)"},"narration":{"type":"string"},"nipSessionId":{"type":"string","description":"Session ID from the name enquiry call"},"idempotencyKey":{"type":"string","format":"uuid","description":"Client-generated UUID, unique per attempt. Reuse the same value when retrying a request that may not have completed (timeout, network drop) — a repeated key returns the original transaction instead of debiting again."}}}}}},"responses":{"200":{"description":"Transfer initiated. Status is confirmed or pending."},"400":{"description":"Validation error, insufficient balance, or transfer failure"},"401":{"description":"Unauthenticated"}}}},"/api/v1/transactions":{"get":{"tags":["Transactions"],"summary":"Fetch transaction history (live from Qore)","security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"page","schema":{"type":"integer","default":1}},{"in":"query","name":"pageSize","schema":{"type":"integer","default":20}}],"responses":{"200":{"description":"Paginated transaction list"},"400":{"description":"No bank account linked yet"},"401":{"description":"Unauthenticated"}}}},"/api/v1/transactions/report":{"get":{"tags":["Transactions"],"summary":"Generate a transaction report","description":"Returns inbound/outbound totals and fees. Use format=csv for a downloadable file.","security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"from","schema":{"type":"string","format":"date","example":"2026-01-01"}},{"in":"query","name":"to","schema":{"type":"string","format":"date","example":"2026-06-30"}},{"in":"query","name":"format","schema":{"type":"string","enum":["json","csv"],"default":"json"}}],"responses":{"200":{"description":"Transaction report (JSON or CSV)"}}}},"/api/v1/transactions/{id}":{"get":{"tags":["Transactions"],"summary":"Get a single transaction's full details","security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Full transaction row"},"404":{"description":"Transaction not found, or doesn't belong to this customer"}}}},"/users/profile":{"get":{"summary":"Get the current user's profile","description":"Returns the authenticated user's account data. The role field is omitted and replaced with an isAdmin boolean.","tags":["Users"],"security":[{"cookieAuth":[]}],"responses":{"200":{"description":"User profile returned successfully","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"type":"string"},"whatsappNumber":{"type":"string"},"isAdmin":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}}}}},"401":{"description":"Unauthorized"},"500":{"description":"Internal server error"}}},"patch":{"summary":"Update the current user's profile","description":"Updates name and/or whatsappNumber for the authenticated account.","tags":["Users"],"security":[{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"whatsappNumber":{"type":"string"}}}}}},"responses":{"200":{"description":"Profile updated successfully"},"401":{"description":"Unauthorized"},"500":{"description":"Internal server error"}}}},"/api/v1/webhooks/qore":{"post":{"tags":["Webhooks"],"summary":"BankOne/Qore switch transaction notification","description":"Called by the BankOne Switch Notification Service whenever a transaction posts to a customer's account. Authenticated by a static shared header (QORE_WEBHOOK_AUTH_HEADER / QORE_WEBHOOK_SECRET), not a customer token. Responds 200 immediately and syncs that customer's deposits in the background.\n","responses":{"200":{"description":"Notification acknowledged"},"401":{"description":"Missing or invalid auth header"}}}}}}