{
	"openapi": "3.0.3",
	"info": {
		"title": "Digital Savings — Portal API",
		"version": "1.0.0",
		"description": "The portal and ops surface for **MoMo Savings+**: the savings product catalogue, the daily liability tally, and the one action an operator can take on a stuck payment order.\n\nEvery answer carries the same envelope — `data` on a success, `error` on a failure, never both — so a caller branches on one field rather than on the status.\n\nEvery route but one is behind a Cognito user pool. Ask `POST /auth/token` for a token, sending the email address or phone number you sign in with and your password, then send it as `Authorization: Bearer <accessToken>`. A token lasts an hour. A request without one is answered `401 Unauthorized` by the gateway, before any of this API runs.\n\nTo use **Try it out**, call `POST /auth/token` first and paste the `accessToken` it returns into **Authorize**. The Postman collection does the same thing with an `{{accessToken}}` variable."
	},
	"servers": [
		{
			"url": "https://portal.develop-savings-ussd-api.kamoa.io",
			"description": "develop — signed with SigV4."
		},
		{
			"url": "https://portal.internal-savings-ussd-api.kamoa.io",
			"description": "internal — signed with SigV4."
		}
	],
	"security": [
		{
			"bearerAuth": []
		}
	],
	"paths": {
		"/auth/token": {
			"post": {
				"operationId": "createPortalToken",
				"summary": "Exchange an email address or phone number and a password for a token.",
				"security": [],
				"requestBody": {
					"required": true,
					"content": {
						"application/json": {
							"schema": {
								"$ref": "#/components/schemas/CreatePortalTokenBody"
							},
							"example": {
								"username": "ops@kamoa.io",
								"password": "…"
							}
						}
					}
				},
				"responses": {
					"200": {
						"description": "The token, under `data`. It lasts an hour.",
						"content": {
							"application/json": {
								"schema": {
									"type": "object"
								}
							}
						}
					},
					"400": {
						"description": "The request could not be read. `error.code` names which field.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					},
					"401": {
						"description": "Those credentials were refused. A wrong password and an unknown user answer the same way.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					},
					"409": {
						"description": "The pool wants something more of this user — a new password, or an MFA code — before a token can be issued.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					}
				}
			}
		},
		"/products/{code}": {
			"get": {
				"operationId": "getProductInForce",
				"summary": "The version of one product in force, whatever its status.",
				"parameters": [
					{
						"name": "code",
						"in": "path",
						"required": true,
						"description": "The product code",
						"schema": {
							"type": "string"
						}
					},
					{
						"name": "asOf",
						"in": "query",
						"required": false,
						"description": "Read the catalogue as at this instant. Absent means now",
						"schema": {
							"type": "string"
						}
					}
				],
				"responses": {
					"200": {
						"description": "One record, under `data`.",
						"content": {
							"application/json": {
								"schema": {
									"type": "object"
								}
							}
						}
					},
					"400": {
						"description": "The request could not be read. `error.code` names which field.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					},
					"404": {
						"description": "Nothing answers for that id — which is not the same as zero.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					}
				}
			}
		},
		"/products/{code}/versions": {
			"get": {
				"operationId": "listProductVersions",
				"summary": "One page of a product's versions, oldest first.",
				"parameters": [
					{
						"name": "code",
						"in": "path",
						"required": true,
						"description": "The product code",
						"schema": {
							"type": "string"
						}
					},
					{
						"name": "limit",
						"in": "query",
						"required": false,
						"description": "Rows per page, 1 to 100. Defaults to 50",
						"schema": {
							"type": "string"
						}
					},
					{
						"name": "cursor",
						"in": "query",
						"required": false,
						"description": "The `nextCursor` of the page before",
						"schema": {
							"type": "string"
						}
					}
				],
				"responses": {
					"200": {
						"description": "A page under `data`, with `count` and a `nextCursor` when one follows.",
						"content": {
							"application/json": {
								"schema": {
									"type": "object"
								}
							}
						}
					},
					"400": {
						"description": "The request could not be read. `error.code` names which field.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					},
					"404": {
						"description": "Nothing answers for that id — which is not the same as zero.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					}
				}
			}
		},
		"/products": {
			"post": {
				"operationId": "createProductVersion",
				"summary": "Append one product version. Versions are immutable.",
				"requestBody": {
					"required": true,
					"content": {
						"application/json": {
							"schema": {
								"$ref": "#/components/schemas/CreateProductVersionBody"
							},
							"example": {
								"productCode": "prod3m",
								"version": "v3",
								"currency": "XAF",
								"termDays": 91,
								"accrualPeriod": "MONTH",
								"accrualPeriods": 3,
								"interestRate": "0.055",
								"discountRate": "0.10",
								"minAmount": "1000",
								"effectiveFrom": "2027-01-01T00:00:00.000Z",
								"status": "ACTIVE"
							}
						}
					}
				},
				"responses": {
					"200": {
						"description": "That version id already existed. The terms sent were discarded and the stored row comes back.",
						"content": {
							"application/json": {
								"schema": {
									"type": "object"
								}
							}
						}
					},
					"201": {
						"description": "The version was written.",
						"content": {
							"application/json": {
								"schema": {
									"type": "object"
								}
							}
						}
					},
					"400": {
						"description": "The request could not be read. `error.code` names which field.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					},
					"409": {
						"description": "Another version of this product already takes effect at that instant.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					}
				}
			}
		},
		"/liability/days": {
			"get": {
				"operationId": "getLiabilityRange",
				"summary": "What the book owed across a window, one row per day.",
				"parameters": [
					{
						"name": "from",
						"in": "query",
						"required": true,
						"description": "First day, YYYY-MM-DD",
						"schema": {
							"type": "string"
						}
					},
					{
						"name": "to",
						"in": "query",
						"required": true,
						"description": "Last day, YYYY-MM-DD",
						"schema": {
							"type": "string"
						}
					}
				],
				"responses": {
					"200": {
						"description": "One row per day under `data`. A day nothing valued carries `rows: 0`.",
						"content": {
							"application/json": {
								"schema": {
									"type": "object"
								}
							}
						}
					},
					"400": {
						"description": "The request could not be read. `error.code` names which field.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					}
				}
			}
		},
		"/liability/days/{asOf}": {
			"get": {
				"operationId": "getLiabilityDay",
				"summary": "What the book owed on one day.",
				"parameters": [
					{
						"name": "asOf",
						"in": "path",
						"required": true,
						"description": "The day, YYYY-MM-DD",
						"schema": {
							"type": "string"
						}
					}
				],
				"responses": {
					"200": {
						"description": "One record, under `data`.",
						"content": {
							"application/json": {
								"schema": {
									"type": "object"
								}
							}
						}
					},
					"400": {
						"description": "The request could not be read. `error.code` names which field.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					},
					"404": {
						"description": "No run valued that day — which is not the same as a day the book owed nothing.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					}
				}
			}
		},
		"/liability/products/{productVersionId}": {
			"get": {
				"operationId": "getProductLiabilitySeries",
				"summary": "One product version's liability over a window, oldest first.",
				"parameters": [
					{
						"name": "productVersionId",
						"in": "path",
						"required": true,
						"description": "The version id, e.g. prod3m#XAF#v2",
						"schema": {
							"type": "string"
						}
					},
					{
						"name": "from",
						"in": "query",
						"required": true,
						"description": "First day, YYYY-MM-DD",
						"schema": {
							"type": "string"
						}
					},
					{
						"name": "to",
						"in": "query",
						"required": true,
						"description": "Last day, YYYY-MM-DD",
						"schema": {
							"type": "string"
						}
					}
				],
				"responses": {
					"200": {
						"description": "The rows under `data`, with `count`.",
						"content": {
							"application/json": {
								"schema": {
									"type": "object"
								}
							}
						}
					},
					"400": {
						"description": "The request could not be read. `error.code` names which field.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					}
				}
			}
		},
		"/orders/{reference}/redrive": {
			"post": {
				"operationId": "redriveStuckOrder",
				"summary": "Clear one stuck order's marker so the overdue sweep picks it up again.",
				"parameters": [
					{
						"name": "reference",
						"in": "path",
						"required": true,
						"description": "The payment order's reference",
						"schema": {
							"type": "string"
						}
					}
				],
				"responses": {
					"200": {
						"description": "The marker was cleared.",
						"content": {
							"application/json": {
								"schema": {
									"type": "object"
								}
							}
						}
					},
					"400": {
						"description": "The request could not be read. `error.code` names which field.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					},
					"404": {
						"description": "That reference is not a stuck PENDING order.",
						"content": {
							"application/json": {
								"schema": {
									"$ref": "#/components/schemas/ErrorEnvelope"
								}
							}
						}
					}
				}
			}
		}
	},
	"components": {
		"schemas": {
			"ErrorEnvelope": {
				"type": "object",
				"description": "Every failure, whatever its status.",
				"properties": {
					"error": {
						"type": "object",
						"properties": {
							"code": {
								"type": "string",
								"description": "A closed set this API owns"
							},
							"message": {
								"type": "string"
							}
						},
						"required": [
							"code",
							"message"
						]
					}
				},
				"required": [
					"error"
				]
			},
			"ListEnvelope": {
				"type": "object",
				"description": "Every list answer. `nextCursor` is present only when a page follows.",
				"properties": {
					"data": {
						"type": "array",
						"items": {
							"type": "object"
						}
					},
					"count": {
						"type": "integer"
					},
					"nextCursor": {
						"type": "string"
					}
				},
				"required": [
					"data",
					"count"
				]
			},
			"CreatePortalTokenBody": {
				"type": "object",
				"properties": {
					"username": {
						"type": "string",
						"description": "The email address or phone number you sign in with"
					},
					"password": {
						"type": "string",
						"description": "Your password"
					}
				},
				"required": [
					"username",
					"password"
				],
				"additionalProperties": false
			},
			"CreateProductVersionBody": {
				"type": "object",
				"properties": {
					"productCode": {
						"type": "string",
						"description": "Code grouping every version of one product",
						"pattern": "^[a-z0-9]+$"
					},
					"version": {
						"type": "string",
						"description": "This version's own label",
						"pattern": "^[a-z0-9]+$"
					},
					"currency": {
						"type": "string",
						"description": "The market this version prices",
						"enum": [
							"XAF"
						]
					},
					"termDays": {
						"type": "integer",
						"description": "Lock term in days"
					},
					"accrualPeriod": {
						"type": "string",
						"description": "Whether interest builds up weekly or monthly",
						"enum": [
							"WEEK",
							"MONTH"
						]
					},
					"accrualPeriods": {
						"type": "integer",
						"description": "How many of those periods a position earns over"
					},
					"interestRate": {
						"type": "string",
						"description": "Annual interest rate as a decimal string",
						"pattern": "^\\d+(\\.\\d+)?$"
					},
					"discountRate": {
						"type": "string",
						"description": "The share of earned interest given up by leaving early",
						"pattern": "^\\d+(\\.\\d+)?$"
					},
					"minAmount": {
						"type": "string",
						"description": "The least a customer can pay into this product",
						"pattern": "^\\d+$"
					},
					"effectiveFrom": {
						"type": "string",
						"description": "When this version takes effect. A future date schedules it"
					},
					"status": {
						"type": "string",
						"description": "Whether this version is offered for new deposits",
						"enum": [
							"ACTIVE",
							"RETIRED"
						]
					}
				},
				"required": [
					"productCode",
					"version",
					"currency",
					"termDays",
					"accrualPeriod",
					"accrualPeriods",
					"interestRate",
					"discountRate",
					"minAmount",
					"effectiveFrom",
					"status"
				],
				"additionalProperties": false
			}
		},
		"securitySchemes": {
			"bearerAuth": {
				"type": "http",
				"scheme": "bearer",
				"bearerFormat": "JWT",
				"description": "The `accessToken` from `POST /auth/token`. It lasts an hour."
			}
		}
	}
}
