{"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":{"/auth/sign-up":{"post":{"summary":"Register a new account","description":"Creates a new account and sends a welcome email. Returns access and refresh tokens.","tags":["Auth"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","email","whatsappNumber"],"properties":{"name":{"type":"string"},"email":{"type":"string","format":"email"},"whatsappNumber":{"type":"string"}}}}}},"responses":{"201":{"description":"Account created successfully. Returns user object, accessToken and refreshToken."},"400":{"description":"Missing fields or email already registered"},"500":{"description":"Internal server error"}}}},"/auth/sign-in":{"post":{"summary":"Sign in to an existing account","description":"Authenticates with email and password. Returns access and refresh tokens.","tags":["Auth"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string","format":"email"},"password":{"type":"string","format":"password"}}}}}},"responses":{"200":{"description":"Signed in successfully. Returns user object, accessToken and refreshToken."},"400":{"description":"Missing email or password"},"401":{"description":"Invalid credentials"},"500":{"description":"Internal server error"}}}},"/auth/sign-out":{"post":{"summary":"Sign out the current user","description":"Clears the auth cookie.","tags":["Auth"],"security":[{"cookieAuth":[]}],"responses":{"200":{"description":"Signed out successfully"},"401":{"description":"Unauthorized"},"500":{"description":"Internal server error"}}}},"/auth/set-password":{"post":{"summary":"Set a password for the authenticated account","description":"Used when an account was created without a password (e.g. via OTP flow) and needs one set.","tags":["Auth"],"security":[{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["password"],"properties":{"password":{"type":"string","format":"password"}}}}}},"responses":{"200":{"description":"Password set successfully"},"400":{"description":"Missing password or bad request"},"401":{"description":"Unauthorized"},"500":{"description":"Internal server error"}}}},"/auth/refresh-token":{"post":{"summary":"Refresh the access token","description":"Accepts a valid refresh token and returns a new access token.","tags":["Auth"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["refreshToken"],"properties":{"refreshToken":{"type":"string"}}}}}},"responses":{"200":{"description":"Returns a new accessToken"},"401":{"description":"Invalid or missing refresh token"},"500":{"description":"Internal server error"}}}},"/auth/send-code":{"post":{"summary":"Send a verification OTP to an email address","description":"Generates a 6-digit OTP and emails it to the provided address. Creates or updates the verification token record.","tags":["Auth"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email"}}}}}},"responses":{"200":{"description":"OTP sent successfully"},"400":{"description":"Missing email"},"500":{"description":"Internal server error"}}}},"/auth/verify-code":{"post":{"summary":"Verify an OTP code","description":"Checks the code against the stored verification token without consuming it. Used to validate the code before proceeding to reset password.","tags":["Auth"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code"],"properties":{"code":{"type":"string","example":"482910"}}}}}},"responses":{"200":{"description":"OTP is valid"},"400":{"description":"Missing code"},"401":{"description":"Invalid or expired OTP"},"500":{"description":"Internal server error"}}}},"/auth/check-email":{"post":{"summary":"Check if an email is already registered","description":"Returns 400 if the email exists, 200 if it is available.","tags":["Auth"],"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"},"500":{"description":"Internal server error"}}}},"/auth/verify-email":{"post":{"summary":"Verify the authenticated user's email with an OTP","description":"Consumes the OTP for the authenticated user's email and marks it as verified.","tags":["Auth"],"security":[{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code"],"properties":{"code":{"type":"string","example":"482910"}}}}}},"responses":{"200":{"description":"Email verified successfully"},"400":{"description":"Missing code"},"401":{"description":"Invalid OTP or unauthorized"},"500":{"description":"Internal server error"}}}},"/auth/forgot-password":{"post":{"summary":"Request a password reset OTP","description":"Sends a password reset OTP to the provided email if an account exists.","tags":["Auth"],"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"},"500":{"description":"Internal server error"}}}},"/auth/reset-password":{"post":{"summary":"Reset password using an OTP","description":"Validates the OTP and sets a new password. Rejects if the new password is the same as the current one.","tags":["Auth"],"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, invalid OTP, or same password used"},"401":{"description":"Invalid OTP"},"500":{"description":"Internal server error"}}}},"/auth/change-password":{"post":{"summary":"Change password for the authenticated user","description":"Requires the current password. Rejects if the new password is the same as the current one.","tags":["Auth"],"security":[{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["password","newPassword"],"properties":{"password":{"type":"string","format":"password","description":"Current password"},"newPassword":{"type":"string","format":"password","description":"New password"}}}}}},"responses":{"200":{"description":"Password changed successfully"},"400":{"description":"Missing fields or same password used"},"401":{"description":"Current password incorrect or unauthorized"},"500":{"description":"Internal server error"}}}},"/oauth/google-sign-in":{"post":{"summary":"Sign in or register with Google","description":"Exchanges a Google authorization code for an access token, fetches the user's\nGoogle profile, then either creates a new account or updates the existing one.\nOn success, sets an httpOnly auth cookie and returns the user object with\naccess and refresh tokens.\n\nIf the account is new, a welcome email is sent automatically.\n","tags":["OAuth"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code"],"properties":{"code":{"type":"string","description":"Google authorization code returned from the OAuth consent screen"}}}}}},"responses":{"200":{"description":"Signed in successfully. Sets httpOnly auth cookie.\nReturns user object (without role), accessToken, refreshToken, and isAdmin flag.\n","content":{"application/json":{"schema":{"type":"object","properties":{"user":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"type":"string"},"avatarUrl":{"type":"string"},"isAdmin":{"type":"boolean"}}},"accessToken":{"type":"string"},"refreshToken":{"type":"string"}}}}}},"500":{"description":"Internal server error or Google OAuth failure"}}}},"/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/admin/customers":{"get":{"tags":["Admin"],"summary":"List all customers with their KYC and account status","responses":{"200":{"description":"Array of customer objects. passwordHash and bvnHash are omitted."}}}},"/api/v1/admin/customers/{id}/freeze":{"post":{"tags":["Admin"],"summary":"Freeze a customer's Qore account and set their status to suspended","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Account frozen."},"404":{"description":"Customer not found"}}}},"/api/v1/admin/customers/{id}/unfreeze":{"post":{"tags":["Admin"],"summary":"Unfreeze a customer's Qore account and restore their status to active","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Account unfrozen."},"404":{"description":"Customer not found"}}}},"/api/v1/admin/disbursements":{"get":{"tags":["Admin"],"summary":"List all disbursement logs across all customers","responses":{"200":{"description":"Array of DisbursementLog objects ordered by createdAt desc. amountKobo serialised as string."}}}},"/api/v1/admin/jobs/failed":{"get":{"tags":["Admin"],"summary":"List all disbursement logs with status failed (ops escalation queue)","responses":{"200":{"description":"Array of failed DisbursementLog objects. amountKobo serialised as string."}}}},"/api/v1/auth/register":{"post":{"tags":["Auth"],"summary":"Step 1 - Register a new customer","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["firstName","lastName","phone","dateOfBirth","gender","address","password"],"properties":{"firstName":{"type":"string"},"lastName":{"type":"string"},"otherNames":{"type":"string"},"phone":{"type":"string","example":"08012345678"},"email":{"type":"string","format":"email"},"dateOfBirth":{"type":"string","example":"1990-01-15"},"gender":{"type":"string","enum":["male","female"]},"address":{"type":"string"},"password":{"type":"string","minLength":8}}}}}},"responses":{"201":{"description":"Customer registered. Use the returned customerId in the OTP steps."},"400":{"description":"Validation error or phone/email already registered"}}}},"/api/v1/auth/send-otp":{"post":{"tags":["Auth"],"summary":"Step 2 - Send OTP to the customer's phone via Termii","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["phone"],"properties":{"phone":{"type":"string","example":"08012345678"}}}}}},"responses":{"200":{"description":"OTP sent. Returns pinId needed for step 3."},"400":{"description":"Validation error"}}}},"/api/v1/auth/verify-otp":{"post":{"tags":["Auth"],"summary":"Step 3 - Verify OTP and receive access + refresh tokens","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["customerId","pinId","pin"],"properties":{"customerId":{"type":"string","format":"uuid"},"pinId":{"type":"string"},"pin":{"type":"string","minLength":6,"maxLength":6}}}}}},"responses":{"200":{"description":"OTP verified. Returns accessToken and refreshToken."},"400":{"description":"Invalid or expired OTP"}}}},"/api/v1/auth/verify-bvn":{"post":{"tags":["Auth"],"summary":"Step 4 - Verify BVN via Qore (requires authentication)","description":"Must be called after verify-otp. Uses the authenticated customer's identity - no customerId in body.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["bvn"],"properties":{"bvn":{"type":"string","minLength":11,"maxLength":11}}}}}},"responses":{"200":{"description":"BVN verified. Returns first name, last name, 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 and password","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["phone","password"],"properties":{"phone":{"type":"string"},"password":{"type":"string"}}}}}},"responses":{"200":{"description":"Returns accessToken and refreshToken."},"401":{"description":"Invalid credentials"}}}},"/api/v1/auth/refresh-token":{"post":{"tags":["Auth"],"summary":"Exchange a 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."},"401":{"description":"Refresh token expired or invalid"}}}},"/api/v1/customers/create-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."},"400":{"description":"Account already exists for this customer"},"401":{"description":"Unauthenticated"}}}},"/api/v1/customers/me":{"get":{"tags":["Customers"],"summary":"Get the authenticated customer's profile and current Qore balance","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Customer profile. bvnHash and passwordHash are omitted."},"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/transactions":{"get":{"tags":["Customers"],"summary":"Fetch transaction history from Qore (live poll)","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Array of raw Qore transaction objects."},"400":{"description":"No bank account linked yet"},"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":["amount","frequency","nextDueAt","withdrawalAccountNumber","withdrawalBankCode"],"properties":{"amount":{"type":"integer","description":"Amount in kobo"},"frequency":{"type":"string","enum":["weekly","monthly"]},"nextDueAt":{"type":"string","format":"date-time"},"withdrawalAccountNumber":{"type":"string"},"withdrawalBankCode":{"type":"string","example":"058"}}}}}},"responses":{"200":{"description":"Plan created or updated. amount is serialised as a string (BigInt)."},"400":{"description":"Validation error"},"401":{"description":"Unauthenticated"}}},"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 DisbursementLog objects ordered by createdAt desc. amountKobo serialised as string."},"401":{"description":"Unauthenticated"}}}}}}