Added transaction feature, fixed some mistakes

This commit is contained in:
2026-04-11 11:12:54 +03:00
parent 6872563c62
commit 545b05d5a0
37 changed files with 2509 additions and 115 deletions
@@ -1,7 +1,7 @@
CREATE TABLE users
(
id BIGSERIAL PRIMARY KEY,
telegram_id BIGINT UNIQUE NOT NULL,
telegram_id BIGINT UNIQUE,
username TEXT,
first_name TEXT NOT NULL,
last_name TEXT,
@@ -3,7 +3,7 @@ CREATE TABLE families
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
owner_id BIGINT NOT NULL REFERENCES users (id),
telegram_chat_id BIGINT NOT NULL,
telegram_chat_id BIGINT,
telegram_chat_name TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS threads;
DROP TYPE IF EXISTS thread_type;
@@ -1,19 +0,0 @@
CREATE TABLE family_threads
(
id BIGSERIAL PRIMARY KEY,
family_id BIGINT NOT NULL REFERENCES families (id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT NOT NULL,
telegram_topic_id BIGINT NOT NULL,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
created_by BIGINT NOT NULL REFERENCES users (id),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (family_id, telegram_topic_id)
);
CREATE UNIQUE INDEX idx_unique_system_threads ON family_threads (family_id, type) WHERE is_system = TRUE;
CREATE INDEX idx_threads_family_id ON family_threads (family_id);
CREATE INDEX idx_threads_family_type ON family_threads (family_id, type);
@@ -1 +1,2 @@
DROP TABLE receipts;
DROP TABLE IF EXISTS receipts;
DROP TABLE IF EXISTS transactions;
@@ -1,6 +1,25 @@
CREATE TABLE transactions
(
id BIGSERIAL PRIMARY KEY,
family_id BIGINT NOT NULL REFERENCES families (id) ON DELETE CASCADE,
description TEXT,
type TEXT NOT NULL,
datetime TIMESTAMP NOT NULL,
category TEXT NOT NULL,
amount NUMERIC(14, 2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT NOT NULL REFERENCES users (id) ON DELETE RESTRICT
);
CREATE INDEX idx_transactions_family_id ON transactions (family_id);
CREATE INDEX idx_transactions_datetime ON transactions (datetime);
CREATE INDEX idx_transactions_created_by ON transactions (created_by);
CREATE INDEX idx_transactions_family_datetime ON transactions (family_id, datetime DESC);
CREATE TABLE receipts
(
id BIGSERIAL PRIMARY KEY,
transaction_id BIGINT UNIQUE REFERENCES transactions (id) ON DELETE SET NULL,
receipt_number TEXT NOT NULL UNIQUE,
ui TEXT NOT NULL,
status INTEGER NOT NULL,
@@ -29,7 +48,8 @@ CREATE TABLE receipts
skno_number TEXT,
unp TEXT,
success TEXT,
created_at TIMESTAMP DEFAULT NOW()
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_receipts_issued_at ON receipts(issued_at);
CREATE INDEX idx_receipts_issued_at ON receipts (issued_at);
CREATE INDEX idx_receipts_transaction_id ON receipts (transaction_id);
+505
View File
@@ -249,6 +249,342 @@ const docTemplate = `{
}
}
},
"/receipts": {
"post": {
"description": "Загружает чек из внешнего сервиса и опционально автоматически создает связанную транзакцию",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Receipts"
],
"summary": "Загрузить чек",
"parameters": [
{
"description": "Receipt payload",
"name": "receipt",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.AddReceiptRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.AddReceiptResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/transactions": {
"get": {
"description": "Возвращает список транзакций с фильтрами и пагинацией",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Transactions"
],
"summary": "Получить список транзакций",
"parameters": [
{
"type": "integer",
"description": "Family ID",
"name": "family_id",
"in": "query"
},
{
"type": "integer",
"description": "User ID",
"name": "created_by",
"in": "query"
},
{
"type": "string",
"description": "Transaction type",
"name": "type",
"in": "query"
},
{
"type": "string",
"description": "Category",
"name": "category",
"in": "query"
},
{
"type": "string",
"description": "RFC3339 start datetime",
"name": "date_from",
"in": "query"
},
{
"type": "string",
"description": "RFC3339 end datetime",
"name": "date_to",
"in": "query"
},
{
"type": "integer",
"description": "Limit, default 50",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.TransactionListResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
},
"post": {
"description": "Создает новую транзакцию и при необходимости привязывает к ней чек",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Transactions"
],
"summary": "Создать транзакцию",
"parameters": [
{
"description": "Transaction payload",
"name": "transaction",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.CreateTransactionRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/dto.TransactionResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/transactions/{id}": {
"get": {
"description": "Возвращает транзакцию по ее внутреннему ID",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Transactions"
],
"summary": "Получить транзакцию по ID",
"parameters": [
{
"type": "integer",
"description": "Transaction ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.TransactionResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
},
"delete": {
"description": "Удаляет транзакцию по ID",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Transactions"
],
"summary": "Удалить транзакцию",
"parameters": [
{
"type": "integer",
"description": "Transaction ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "no content",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
},
"patch": {
"description": "Частично обновляет данные транзакции и связь с чеком",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Transactions"
],
"summary": "Обновить транзакцию",
"parameters": [
{
"type": "integer",
"description": "Transaction ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Transaction patch payload",
"name": "transaction",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.UpdateTransactionRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.TransactionResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/users": {
"post": {
"consumes": [
@@ -501,6 +837,55 @@ const docTemplate = `{
}
},
"definitions": {
"domain.AddReceiptRequest": {
"type": "object",
"required": [
"date",
"number"
],
"properties": {
"category": {
"type": "string"
},
"created_by": {
"type": "integer"
},
"date": {
"type": "string"
},
"description": {
"type": "string"
},
"family_id": {
"type": "integer"
},
"number": {
"type": "string",
"maxLength": 24,
"minLength": 24
},
"type": {
"type": "string"
}
}
},
"domain.AddReceiptResponse": {
"type": "object",
"properties": {
"date": {
"type": "string"
},
"id": {
"type": "integer"
},
"number": {
"type": "string"
},
"transaction_id": {
"type": "integer"
}
}
},
"domain.CreateFamilyRequest": {
"type": "object",
"properties": {
@@ -574,6 +959,9 @@ const docTemplate = `{
"name": {
"type": "string"
},
"telegram_chat_id": {
"type": "integer"
},
"telegram_chat_name": {
"type": "string"
}
@@ -632,6 +1020,123 @@ const docTemplate = `{
"type": "string"
}
}
},
"dto.CreateTransactionRequest": {
"type": "object",
"required": [
"amount",
"category",
"created_by",
"datetime",
"family_id",
"type"
],
"properties": {
"amount": {
"type": "number"
},
"category": {
"type": "string"
},
"created_by": {
"type": "integer"
},
"datetime": {
"type": "string"
},
"description": {
"type": "string"
},
"family_id": {
"type": "integer"
},
"receipt_id": {
"type": "integer"
},
"type": {
"type": "string"
}
}
},
"dto.ErrorResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
},
"dto.TransactionListResponse": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.TransactionResponse"
}
}
}
},
"dto.TransactionResponse": {
"type": "object",
"properties": {
"amount": {
"type": "number"
},
"category": {
"type": "string"
},
"created_at": {
"type": "string"
},
"created_by": {
"type": "integer"
},
"datetime": {
"type": "string"
},
"description": {
"type": "string"
},
"family_id": {
"type": "integer"
},
"id": {
"type": "integer"
},
"receipt_id": {
"type": "integer"
},
"type": {
"type": "string"
}
}
},
"dto.UpdateTransactionRequest": {
"type": "object",
"properties": {
"amount": {
"type": "number"
},
"category": {
"type": "string"
},
"datetime": {
"type": "string"
},
"description": {
"type": "string"
},
"detach_receipt": {
"type": "boolean"
},
"receipt_id": {
"type": "integer"
},
"type": {
"type": "string"
}
}
}
}
}`
+505
View File
@@ -238,6 +238,342 @@
}
}
},
"/receipts": {
"post": {
"description": "Загружает чек из внешнего сервиса и опционально автоматически создает связанную транзакцию",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Receipts"
],
"summary": "Загрузить чек",
"parameters": [
{
"description": "Receipt payload",
"name": "receipt",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.AddReceiptRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.AddReceiptResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/transactions": {
"get": {
"description": "Возвращает список транзакций с фильтрами и пагинацией",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Transactions"
],
"summary": "Получить список транзакций",
"parameters": [
{
"type": "integer",
"description": "Family ID",
"name": "family_id",
"in": "query"
},
{
"type": "integer",
"description": "User ID",
"name": "created_by",
"in": "query"
},
{
"type": "string",
"description": "Transaction type",
"name": "type",
"in": "query"
},
{
"type": "string",
"description": "Category",
"name": "category",
"in": "query"
},
{
"type": "string",
"description": "RFC3339 start datetime",
"name": "date_from",
"in": "query"
},
{
"type": "string",
"description": "RFC3339 end datetime",
"name": "date_to",
"in": "query"
},
{
"type": "integer",
"description": "Limit, default 50",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.TransactionListResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
},
"post": {
"description": "Создает новую транзакцию и при необходимости привязывает к ней чек",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Transactions"
],
"summary": "Создать транзакцию",
"parameters": [
{
"description": "Transaction payload",
"name": "transaction",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.CreateTransactionRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/dto.TransactionResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/transactions/{id}": {
"get": {
"description": "Возвращает транзакцию по ее внутреннему ID",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Transactions"
],
"summary": "Получить транзакцию по ID",
"parameters": [
{
"type": "integer",
"description": "Transaction ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.TransactionResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
},
"delete": {
"description": "Удаляет транзакцию по ID",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Transactions"
],
"summary": "Удалить транзакцию",
"parameters": [
{
"type": "integer",
"description": "Transaction ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "no content",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
},
"patch": {
"description": "Частично обновляет данные транзакции и связь с чеком",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Transactions"
],
"summary": "Обновить транзакцию",
"parameters": [
{
"type": "integer",
"description": "Transaction ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Transaction patch payload",
"name": "transaction",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.UpdateTransactionRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.TransactionResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
},
"/users": {
"post": {
"consumes": [
@@ -490,6 +826,55 @@
}
},
"definitions": {
"domain.AddReceiptRequest": {
"type": "object",
"required": [
"date",
"number"
],
"properties": {
"category": {
"type": "string"
},
"created_by": {
"type": "integer"
},
"date": {
"type": "string"
},
"description": {
"type": "string"
},
"family_id": {
"type": "integer"
},
"number": {
"type": "string",
"maxLength": 24,
"minLength": 24
},
"type": {
"type": "string"
}
}
},
"domain.AddReceiptResponse": {
"type": "object",
"properties": {
"date": {
"type": "string"
},
"id": {
"type": "integer"
},
"number": {
"type": "string"
},
"transaction_id": {
"type": "integer"
}
}
},
"domain.CreateFamilyRequest": {
"type": "object",
"properties": {
@@ -563,6 +948,9 @@
"name": {
"type": "string"
},
"telegram_chat_id": {
"type": "integer"
},
"telegram_chat_name": {
"type": "string"
}
@@ -621,6 +1009,123 @@
"type": "string"
}
}
},
"dto.CreateTransactionRequest": {
"type": "object",
"required": [
"amount",
"category",
"created_by",
"datetime",
"family_id",
"type"
],
"properties": {
"amount": {
"type": "number"
},
"category": {
"type": "string"
},
"created_by": {
"type": "integer"
},
"datetime": {
"type": "string"
},
"description": {
"type": "string"
},
"family_id": {
"type": "integer"
},
"receipt_id": {
"type": "integer"
},
"type": {
"type": "string"
}
}
},
"dto.ErrorResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
},
"dto.TransactionListResponse": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.TransactionResponse"
}
}
}
},
"dto.TransactionResponse": {
"type": "object",
"properties": {
"amount": {
"type": "number"
},
"category": {
"type": "string"
},
"created_at": {
"type": "string"
},
"created_by": {
"type": "integer"
},
"datetime": {
"type": "string"
},
"description": {
"type": "string"
},
"family_id": {
"type": "integer"
},
"id": {
"type": "integer"
},
"receipt_id": {
"type": "integer"
},
"type": {
"type": "string"
}
}
},
"dto.UpdateTransactionRequest": {
"type": "object",
"properties": {
"amount": {
"type": "number"
},
"category": {
"type": "string"
},
"datetime": {
"type": "string"
},
"description": {
"type": "string"
},
"detach_receipt": {
"type": "boolean"
},
"receipt_id": {
"type": "integer"
},
"type": {
"type": "string"
}
}
}
}
}
+337
View File
@@ -1,4 +1,37 @@
definitions:
domain.AddReceiptRequest:
properties:
category:
type: string
created_by:
type: integer
date:
type: string
description:
type: string
family_id:
type: integer
number:
maxLength: 24
minLength: 24
type: string
type:
type: string
required:
- date
- number
type: object
domain.AddReceiptResponse:
properties:
date:
type: string
id:
type: integer
number:
type: string
transaction_id:
type: integer
type: object
domain.CreateFamilyRequest:
properties:
name:
@@ -47,6 +80,8 @@ definitions:
properties:
name:
type: string
telegram_chat_id:
type: integer
telegram_chat_name:
type: string
type: object
@@ -85,6 +120,84 @@ definitions:
username:
type: string
type: object
dto.CreateTransactionRequest:
properties:
amount:
type: number
category:
type: string
created_by:
type: integer
datetime:
type: string
description:
type: string
family_id:
type: integer
receipt_id:
type: integer
type:
type: string
required:
- amount
- category
- created_by
- datetime
- family_id
- type
type: object
dto.ErrorResponse:
properties:
message:
type: string
type: object
dto.TransactionListResponse:
properties:
items:
items:
$ref: '#/definitions/dto.TransactionResponse'
type: array
type: object
dto.TransactionResponse:
properties:
amount:
type: number
category:
type: string
created_at:
type: string
created_by:
type: integer
datetime:
type: string
description:
type: string
family_id:
type: integer
id:
type: integer
receipt_id:
type: integer
type:
type: string
type: object
dto.UpdateTransactionRequest:
properties:
amount:
type: number
category:
type: string
datetime:
type: string
description:
type: string
detach_receipt:
type: boolean
receipt_id:
type: integer
type:
type: string
type: object
info:
contact: {}
paths:
@@ -243,6 +356,230 @@ paths:
summary: Обновить семью
tags:
- Families
/receipts:
post:
consumes:
- application/json
description: Загружает чек из внешнего сервиса и опционально автоматически создает
связанную транзакцию
parameters:
- description: Receipt payload
in: body
name: receipt
required: true
schema:
$ref: '#/definitions/domain.AddReceiptRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.AddReceiptResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Загрузить чек
tags:
- Receipts
/transactions:
get:
consumes:
- application/json
description: Возвращает список транзакций с фильтрами и пагинацией
parameters:
- description: Family ID
in: query
name: family_id
type: integer
- description: User ID
in: query
name: created_by
type: integer
- description: Transaction type
in: query
name: type
type: string
- description: Category
in: query
name: category
type: string
- description: RFC3339 start datetime
in: query
name: date_from
type: string
- description: RFC3339 end datetime
in: query
name: date_to
type: string
- description: Limit, default 50
in: query
name: limit
type: integer
- description: Offset
in: query
name: offset
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/dto.TransactionListResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Получить список транзакций
tags:
- Transactions
post:
consumes:
- application/json
description: Создает новую транзакцию и при необходимости привязывает к ней
чек
parameters:
- description: Transaction payload
in: body
name: transaction
required: true
schema:
$ref: '#/definitions/dto.CreateTransactionRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/dto.TransactionResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Создать транзакцию
tags:
- Transactions
/transactions/{id}:
delete:
consumes:
- application/json
description: Удаляет транзакцию по ID
parameters:
- description: Transaction ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"204":
description: no content
schema:
type: string
"400":
description: Bad Request
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Удалить транзакцию
tags:
- Transactions
get:
consumes:
- application/json
description: Возвращает транзакцию по ее внутреннему ID
parameters:
- description: Transaction ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/dto.TransactionResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Получить транзакцию по ID
tags:
- Transactions
patch:
consumes:
- application/json
description: Частично обновляет данные транзакции и связь с чеком
parameters:
- description: Transaction ID
in: path
name: id
required: true
type: integer
- description: Transaction patch payload
in: body
name: transaction
required: true
schema:
$ref: '#/definitions/dto.UpdateTransactionRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/dto.TransactionResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/dto.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/dto.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Обновить транзакцию
tags:
- Transactions
/users:
post:
consumes:
+79
View File
@@ -0,0 +1,79 @@
package dto
import (
"FamilyHub/src/domain"
"time"
)
type CreateTransactionRequest struct {
FamilyID int64 `json:"family_id" binding:"required"`
Description *string `json:"description"`
Type string `json:"type" binding:"required"`
DateTime string `json:"datetime" binding:"required"`
Category string `json:"category" binding:"required"`
Amount float64 `json:"amount" binding:"required"`
CreatedBy int64 `json:"created_by" binding:"required"`
ReceiptID *int64 `json:"receipt_id"`
}
type UpdateTransactionRequest struct {
Description *string `json:"description"`
Type *string `json:"type"`
DateTime *string `json:"datetime"`
Category *string `json:"category"`
Amount *float64 `json:"amount"`
ReceiptID *int64 `json:"receipt_id"`
DetachReceipt bool `json:"detach_receipt"`
}
type ListTransactionsQuery struct {
FamilyID *int64 `form:"family_id"`
CreatedBy *int64 `form:"created_by"`
Type *string `form:"type"`
Category *string `form:"category"`
DateFrom *string `form:"date_from"`
DateTo *string `form:"date_to"`
Limit int `form:"limit"`
Offset int `form:"offset"`
}
type TransactionResponse struct {
ID int64 `json:"id"`
FamilyID int64 `json:"family_id"`
Description *string `json:"description"`
Type string `json:"type"`
DateTime string `json:"datetime"`
Category string `json:"category"`
Amount float64 `json:"amount"`
CreatedAt string `json:"created_at"`
CreatedBy int64 `json:"created_by"`
ReceiptID *int64 `json:"receipt_id"`
}
type TransactionListResponse struct {
Items []TransactionResponse `json:"items"`
}
func TransactionToResponse(transaction *domain.Transaction) TransactionResponse {
return TransactionResponse{
ID: transaction.ID,
FamilyID: transaction.FamilyID,
Description: transaction.Description,
Type: transaction.Type,
DateTime: transaction.DateTime.Format(time.RFC3339),
Category: transaction.Category,
Amount: transaction.Amount,
CreatedAt: transaction.CreatedAt.Format(time.RFC3339),
CreatedBy: transaction.CreatedBy,
ReceiptID: transaction.ReceiptID,
}
}
func TransactionsToListResponse(transactions []*domain.Transaction) TransactionListResponse {
items := make([]TransactionResponse, 0, len(transactions))
for _, transaction := range transactions {
items = append(items, TransactionToResponse(transaction))
}
return TransactionListResponse{Items: items}
}
+13 -3
View File
@@ -5,7 +5,9 @@ import (
"FamilyHub/src/domain"
"database/sql"
"errors"
"log"
"net/http"
"runtime/debug"
"strconv"
"github.com/gin-gonic/gin"
@@ -23,7 +25,7 @@ func (router *FamiliesRouter) RegisterRoutes(r *gin.RouterGroup) {
families := r.Group("/families")
{
families.POST("", router.Create)
families.GET("/:id", router.GetByID)
families.GET("/:id", router.Read)
families.PATCH("/:id", router.Update)
families.DELETE("/:id", router.Delete)
}
@@ -58,7 +60,7 @@ func (router *FamiliesRouter) Create(c *gin.Context) {
c.JSON(http.StatusCreated, resp.ModelToResponse(family))
}
// GetByID GoDoc
// Read GoDoc
// @Summary Получить семью по ID
// @Description Возвращает семью по ее внутреннему ID
// @Tags Families
@@ -70,7 +72,7 @@ func (router *FamiliesRouter) Create(c *gin.Context) {
// @Failure 404 {object} map[string]string "family not found"
// @Failure 500 {object} map[string]string "internal server error"
// @Router /families/{id} [get]
func (router *FamiliesRouter) GetByID(c *gin.Context) {
func (router *FamiliesRouter) Read(c *gin.Context) {
var resp domain.FamilyResponse
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
@@ -164,6 +166,14 @@ func handleFamilyError(c *gin.Context, err error) {
case errors.Is(err, sql.ErrNoRows):
c.JSON(http.StatusNotFound, gin.H{"error": "family not found"})
default:
log.Printf(
"family request failed: method=%s path=%s route=%s error=%v\n%s",
c.Request.Method,
c.Request.URL.Path,
c.FullPath(),
err,
debug.Stack(),
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
}
}
+15 -4
View File
@@ -19,6 +19,14 @@ import (
"github.com/stretchr/testify/require"
)
func int64Ptr(v int64) *int64 {
return &v
}
func stringPtr(v string) *string {
return &v
}
type familyServiceMock struct {
createFn func(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error)
getByIDFn func(ctx context.Context, id int64) (*domain.Family, error)
@@ -68,8 +76,8 @@ func sampleFamily() *domain.Family {
ID: 7,
Name: "Belan",
OwnerID: 10,
TelegramChatID: 12345,
TelegramChatName: "Family Chat",
TelegramChatID: int64Ptr(12345),
TelegramChatName: stringPtr("Family Chat"),
CreatedAt: time.Date(2026, time.January, 21, 10, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, time.January, 21, 11, 0, 0, 0, time.UTC),
}
@@ -106,9 +114,11 @@ func TestFamiliesRouter_Create(t *testing.T) {
expected := sampleFamily()
r := setupFamiliesRouter(&familyServiceMock{createFn: func(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error) {
assert.Equal(t, "Belan", req.Name)
assert.Nil(t, req.TelegramChatID)
assert.Nil(t, req.TelegramChatName)
return expected, nil
}})
req := httptest.NewRequest(http.MethodPost, "/api/v1/families", bytes.NewBufferString(`{"name":"Belan","owner_id":10,"telegram_chat_id":12345,"telegram_chat_name":"Family Chat"}`))
req := httptest.NewRequest(http.MethodPost, "/api/v1/families", bytes.NewBufferString(`{"name":"Belan","owner_id":10}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
@@ -233,7 +243,8 @@ func TestFamiliesRouter_Update(t *testing.T) {
assert.Equal(t, int64(7), id)
require.NotNil(t, req.Name)
assert.Equal(t, updatedName, *req.Name)
assert.Equal(t, "Updated", req.TelegramChatName)
require.NotNil(t, req.TelegramChatName)
assert.Equal(t, "Updated", *req.TelegramChatName)
return expected, nil
}})
req := httptest.NewRequest(http.MethodPatch, "/api/v1/families/7", bytes.NewBufferString(`{"name":"`+updatedName+`","telegram_chat_name":"Updated"}`))
+19 -5
View File
@@ -13,7 +13,7 @@ import (
)
type receiptService interface {
GetReceipt(ctx context.Context, date string, number string) (*domain.Receipt, error)
GetReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error)
}
type ReceiptRouter struct {
@@ -30,6 +30,17 @@ func (router *ReceiptRouter) RegisterRoutes(r *gin.RouterGroup) {
}
}
// AddReceipt GoDoc
// @Summary Загрузить чек
// @Description Загружает чек из внешнего сервиса и опционально автоматически создает связанную транзакцию
// @Tags Receipts
// @Accept json
// @Produce json
// @Param receipt body domain.AddReceiptRequest true "Receipt payload"
// @Success 200 {object} domain.AddReceiptResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /receipts [post]
func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) {
var req domain.AddReceiptRequest
if err := context_.ShouldBindJSON(&req); err != nil {
@@ -46,7 +57,9 @@ func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
receipt, err := router.service.GetReceipt(ctx, isoDate, req.Number)
req.Date = isoDate
receipt, err := router.service.GetReceipt(ctx, req)
if err != nil {
context_.JSON(400, gin.H{"error": err.Error()})
log.Printf("API error, %s", err.Error())
@@ -54,9 +67,10 @@ func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) {
}
resp := domain.AddReceiptResponse{
ID: 1,
Number: receipt.ReceiptNumber,
Date: receipt.IssuedAt,
ID: int32(receipt.ID),
Number: receipt.ReceiptNumber,
Date: receipt.IssuedAt,
TransactionID: receipt.TransactionID,
}
context_.JSON(http.StatusOK, resp)
}
+11 -11
View File
@@ -18,12 +18,12 @@ import (
)
type receiptServiceMock struct {
getReceiptFn func(ctx context.Context, date string, number string) (*domain.Receipt, error)
getReceiptFn func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error)
}
func (m *receiptServiceMock) GetReceipt(ctx context.Context, date string, number string) (*domain.Receipt, error) {
func (m *receiptServiceMock) GetReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
if m.getReceiptFn != nil {
return m.getReceiptFn(ctx, date, number)
return m.getReceiptFn(ctx, req)
}
return nil, errors.New("mock is not configured")
}
@@ -60,9 +60,9 @@ func TestReceiptRouter_AddReceipt(t *testing.T) {
{
name: "bad request on service error",
body: `{"number":"` + validNumber + `","date":"` + validDate + `"}`,
mock: &receiptServiceMock{getReceiptFn: func(ctx context.Context, date string, number string) (*domain.Receipt, error) {
assert.Equal(t, expectedDate, date)
assert.Equal(t, validNumber, number)
mock: &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
assert.Equal(t, expectedDate, req.Date)
assert.Equal(t, validNumber, req.Number)
return nil, errors.New("receipt not found")
}},
expectedStatus: http.StatusBadRequest,
@@ -71,10 +71,10 @@ func TestReceiptRouter_AddReceipt(t *testing.T) {
{
name: "ok",
body: `{"number":"` + validNumber + `","date":"` + validDate + `"}`,
mock: &receiptServiceMock{getReceiptFn: func(ctx context.Context, date string, number string) (*domain.Receipt, error) {
assert.Equal(t, expectedDate, date)
assert.Equal(t, validNumber, number)
return &domain.Receipt{ReceiptNumber: validNumber, IssuedAt: now}, nil
mock: &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
assert.Equal(t, expectedDate, req.Date)
assert.Equal(t, validNumber, req.Number)
return &domain.Receipt{ID: 7, ReceiptNumber: validNumber, IssuedAt: now}, nil
}},
expectedStatus: http.StatusOK,
expectedContains: validNumber,
@@ -108,7 +108,7 @@ func TestReceiptRouter_AddReceipt(t *testing.T) {
}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, int32(1), resp.ID)
assert.Equal(t, int32(7), resp.ID)
assert.Equal(t, validNumber, resp.Number)
assert.Equal(t, now, resp.Date)
}
+266
View File
@@ -0,0 +1,266 @@
package routers
import (
"FamilyHub/src/api/dto"
"FamilyHub/src/api/services"
"FamilyHub/src/domain"
"errors"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
type TransactionsRouter struct {
service services.TransactionService
}
func NewTransactionsRouter(s services.TransactionService) *TransactionsRouter {
return &TransactionsRouter{service: s}
}
func (router *TransactionsRouter) RegisterRoutes(r *gin.RouterGroup) {
transactions := r.Group("/transactions")
{
transactions.POST("", router.Create)
transactions.GET("", router.List)
transactions.GET("/:id", router.Read)
transactions.PATCH("/:id", router.Update)
transactions.DELETE("/:id", router.Delete)
}
}
// Create GoDoc
// @Summary Создать транзакцию
// @Description Создает новую транзакцию и при необходимости привязывает к ней чек
// @Tags Transactions
// @Accept json
// @Produce json
// @Param transaction body dto.CreateTransactionRequest true "Transaction payload"
// @Success 201 {object} dto.TransactionResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions [post]
func (router *TransactionsRouter) Create(c *gin.Context) {
var req dto.CreateTransactionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
dateTime, err := time.Parse(time.RFC3339, req.DateTime)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "datetime must be RFC3339"})
return
}
transaction, err := router.service.Create(c.Request.Context(), domain.CreateTransactionRequest{
FamilyID: req.FamilyID,
Description: req.Description,
Type: req.Type,
DateTime: dateTime,
Category: req.Category,
Amount: req.Amount,
CreatedBy: req.CreatedBy,
ReceiptID: req.ReceiptID,
})
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusCreated, dto.TransactionToResponse(transaction))
}
// List GoDoc
// @Summary Получить список транзакций
// @Description Возвращает список транзакций с фильтрами и пагинацией
// @Tags Transactions
// @Accept json
// @Produce json
// @Param family_id query int false "Family ID"
// @Param created_by query int false "User ID"
// @Param type query string false "Transaction type"
// @Param category query string false "Category"
// @Param date_from query string false "RFC3339 start datetime"
// @Param date_to query string false "RFC3339 end datetime"
// @Param limit query int false "Limit, default 50"
// @Param offset query int false "Offset"
// @Success 200 {object} dto.TransactionListResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions [get]
func (router *TransactionsRouter) List(c *gin.Context) {
var query dto.ListTransactionsQuery
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
filter, err := transactionQueryToFilter(query)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
transactions, err := router.service.List(c.Request.Context(), filter)
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusOK, dto.TransactionsToListResponse(transactions))
}
// Read GoDoc
// @Summary Получить транзакцию по ID
// @Description Возвращает транзакцию по ее внутреннему ID
// @Tags Transactions
// @Accept json
// @Produce json
// @Param id path int true "Transaction ID"
// @Success 200 {object} dto.TransactionResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions/{id} [get]
func (router *TransactionsRouter) Read(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "invalid id"})
return
}
transaction, err := router.service.GetByID(c.Request.Context(), id)
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusOK, dto.TransactionToResponse(transaction))
}
// Update GoDoc
// @Summary Обновить транзакцию
// @Description Частично обновляет данные транзакции и связь с чеком
// @Tags Transactions
// @Accept json
// @Produce json
// @Param id path int true "Transaction ID"
// @Param transaction body dto.UpdateTransactionRequest true "Transaction patch payload"
// @Success 200 {object} dto.TransactionResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions/{id} [patch]
func (router *TransactionsRouter) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "invalid id"})
return
}
var req dto.UpdateTransactionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
var dateTime *time.Time
if req.DateTime != nil {
parsed, err := time.Parse(time.RFC3339, *req.DateTime)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "datetime must be RFC3339"})
return
}
dateTime = &parsed
}
transaction, err := router.service.Update(c.Request.Context(), id, domain.UpdateTransactionRequest{
Description: req.Description,
Type: req.Type,
DateTime: dateTime,
Category: req.Category,
Amount: req.Amount,
ReceiptID: req.ReceiptID,
DetachReceipt: req.DetachReceipt,
})
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusOK, dto.TransactionToResponse(transaction))
}
// Delete GoDoc
// @Summary Удалить транзакцию
// @Description Удаляет транзакцию по ID
// @Tags Transactions
// @Accept json
// @Produce json
// @Param id path int true "Transaction ID"
// @Success 204 {string} string "no content"
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions/{id} [delete]
func (router *TransactionsRouter) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "invalid id"})
return
}
if err := router.service.Delete(c.Request.Context(), id); err != nil {
handleTransactionError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func handleTransactionError(c *gin.Context, err error) {
switch {
case errors.Is(err, services.ErrTransactionNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrTransactionPatch),
errors.Is(err, services.ErrReceiptLinkConflict),
errors.Is(err, services.ErrInvalidTransaction):
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrReceiptNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
default:
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
}
}
func transactionQueryToFilter(query dto.ListTransactionsQuery) (domain.TransactionListFilter, error) {
filter := domain.TransactionListFilter{
FamilyID: query.FamilyID,
CreatedBy: query.CreatedBy,
Type: query.Type,
Category: query.Category,
Limit: query.Limit,
Offset: query.Offset,
}
if query.DateFrom != nil {
parsed, err := time.Parse(time.RFC3339, *query.DateFrom)
if err != nil {
return domain.TransactionListFilter{}, errors.New("date_from must be RFC3339")
}
filter.DateFrom = &parsed
}
if query.DateTo != nil {
parsed, err := time.Parse(time.RFC3339, *query.DateTo)
if err != nil {
return domain.TransactionListFilter{}, errors.New("date_to must be RFC3339")
}
filter.DateTo = &parsed
}
return filter, nil
}
+7 -7
View File
@@ -21,15 +21,15 @@ func NewUsersRouter(s services.UserService) *UsersRouter {
func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) {
users := r.Group("/users")
{
users.POST("", router.CreateUser)
users.GET("/:id", router.GetByID)
users.GET("/by-telegram/:telegramId", router.GetByTelegramID)
users.POST("", router.Create)
users.GET("/:id", router.Read)
users.PATCH("/:id", router.Update)
users.DELETE("/:id", router.Delete)
users.GET("/by-telegram/:telegramId", router.GetByTelegramID)
}
}
// CreateUser GoDoc
// Create GoDoc
// @Summary Создать пользователя
// @Tags Users
// @Accept json
@@ -39,7 +39,7 @@ func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) {
// @Failure 400 {object} domain.UserErrorResponse
// @Failure 500 {object} domain.UserErrorResponse
// @Router /users [post]
func (router *UsersRouter) CreateUser(c *gin.Context) {
func (router *UsersRouter) Create(c *gin.Context) {
var req domain.CreateUserRequest
var resp domain.UserResponse
@@ -57,7 +57,7 @@ func (router *UsersRouter) CreateUser(c *gin.Context) {
c.JSON(http.StatusCreated, resp.ModelToResponse(user))
}
// GetByID GoDoc
// Read GoDoc
// @Summary Получить пользователя по ID
// @Description Возвращает пользователя по его внутреннему ID
// @Tags Users
@@ -69,7 +69,7 @@ func (router *UsersRouter) CreateUser(c *gin.Context) {
// @Failure 404 {object} domain.UserErrorResponse "user not found"
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
// @Router /users/{id} [get]
func (router *UsersRouter) GetByID(c *gin.Context) {
func (router *UsersRouter) Read(c *gin.Context) {
var resp domain.UserResponse
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
+12 -3
View File
@@ -12,6 +12,7 @@ import (
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"github.com/gin-gonic/gin"
@@ -36,8 +37,10 @@ func NewServer(cfg config.Config) *Server {
log.Fatal(err)
}
gin.SetMode(gin.ReleaseMode)
router := gin.Default()
//gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.Use(gin.Logger())
router.Use(gin.RecoveryWithWriter(os.Stderr))
if cfg.OpenAPIEnabled {
openAPIEndpoint := cfg.OpenAPIEndpoint
if openAPIEndpoint == "" {
@@ -84,11 +87,17 @@ func NewServer(cfg config.Config) *Server {
apiV1 := router.Group("/api/v1")
transactionRepo := repositories.NewTransactionsSQLRepository(dbConn)
receiptRepo := repositories.NewReceiptsSQLRepository(dbConn)
receiptService_ := receiptService.NewReceiptService(receiptRepo)
receiptService_ := receiptService.NewReceiptService(receiptRepo, transactionRepo)
receiptRouter := routers.NewReceiptRouter(receiptService_)
receiptRouter.RegisterRoutes(apiV1)
transactionService := services.NewTransactionService(transactionRepo)
transactionRouter := routers.NewTransactionsRouter(transactionService)
transactionRouter.RegisterRoutes(apiV1)
usersRepo := repositories.NewUsersSQLRepository(dbConn)
usersService := services.NewUserService(usersRepo)
usersRouter := routers.NewUsersRouter(usersService)
+1 -1
View File
@@ -61,7 +61,7 @@ func (s *familyService) Update(ctx context.Context, id int64, req domain.UpdateF
ID: id,
Name: *req.Name,
OwnerID: existing.OwnerID,
TelegramChatID: existing.TelegramChatID,
TelegramChatID: req.TelegramChatID,
TelegramChatName: req.TelegramChatName,
}); err != nil {
return nil, err
+177
View File
@@ -0,0 +1,177 @@
package services
import (
"FamilyHub/src/domain"
"FamilyHub/src/repositories"
"context"
"database/sql"
"errors"
"strings"
)
type TransactionService interface {
Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error)
GetByID(ctx context.Context, id int64) (*domain.Transaction, error)
List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error)
Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error)
Delete(ctx context.Context, id int64) error
}
type transactionService struct {
repo repositories.TransactionRepository
}
func NewTransactionService(repo repositories.TransactionRepository) TransactionService {
return &transactionService{repo: repo}
}
var (
ErrTransactionNotFound = errors.New("transaction not found")
ErrTransactionPatch = errors.New("empty update payload")
ErrReceiptLinkConflict = errors.New("receipt_id and detach_receipt cannot be used together")
ErrInvalidTransaction = errors.New("type and category are required")
ErrReceiptNotFound = errors.New("receipt not found")
)
func (s *transactionService) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) {
if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Category) == "" {
return nil, ErrInvalidTransaction
}
transaction := &domain.Transaction{
FamilyID: req.FamilyID,
Description: req.Description,
Type: req.Type,
DateTime: req.DateTime,
Category: req.Category,
Amount: req.Amount,
CreatedBy: req.CreatedBy,
ReceiptID: req.ReceiptID,
}
if err := s.repo.Create(ctx, transaction); err != nil {
if errors.Is(err, repositories.ErrReceiptNotFound) {
return nil, ErrReceiptNotFound
}
return nil, err
}
return transaction, nil
}
func (s *transactionService) GetByID(ctx context.Context, id int64) (*domain.Transaction, error) {
transaction, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if transaction == nil {
return nil, ErrTransactionNotFound
}
return transaction, nil
}
func (s *transactionService) List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) {
if filter.Limit <= 0 {
filter.Limit = 50
}
if filter.Limit > 200 {
filter.Limit = 200
}
if filter.Offset < 0 {
filter.Offset = 0
}
return s.repo.List(ctx, filter)
}
func (s *transactionService) Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error) {
if req.ReceiptID != nil && req.DetachReceipt {
return nil, ErrReceiptLinkConflict
}
existing, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if existing == nil {
return nil, ErrTransactionNotFound
}
if req.Description == nil &&
req.Type == nil &&
req.DateTime == nil &&
req.Category == nil &&
req.Amount == nil &&
req.ReceiptID == nil &&
!req.DetachReceipt {
return nil, ErrTransactionPatch
}
updated := &domain.Transaction{
ID: existing.ID,
FamilyID: existing.FamilyID,
Description: existing.Description,
Type: existing.Type,
DateTime: existing.DateTime,
Category: existing.Category,
Amount: existing.Amount,
CreatedAt: existing.CreatedAt,
CreatedBy: existing.CreatedBy,
ReceiptID: existing.ReceiptID,
}
if req.Description != nil {
updated.Description = req.Description
}
if req.Type != nil {
updated.Type = *req.Type
}
if req.DateTime != nil {
updated.DateTime = *req.DateTime
}
if req.Category != nil {
updated.Category = *req.Category
}
if req.Amount != nil {
updated.Amount = *req.Amount
}
syncReceipt := false
if req.DetachReceipt {
updated.ReceiptID = nil
syncReceipt = true
}
if req.ReceiptID != nil {
updated.ReceiptID = req.ReceiptID
syncReceipt = true
}
if strings.TrimSpace(updated.Type) == "" || strings.TrimSpace(updated.Category) == "" {
return nil, ErrInvalidTransaction
}
if err := s.repo.Update(ctx, updated, syncReceipt); err != nil {
if errors.Is(err, repositories.ErrReceiptNotFound) {
return nil, ErrReceiptNotFound
}
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrTransactionNotFound
}
return nil, err
}
return s.repo.GetByID(ctx, id)
}
func (s *transactionService) Delete(ctx context.Context, id int64) error {
transaction, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
if transaction == nil {
return ErrTransactionNotFound
}
return s.repo.Delete(ctx, id)
}
+3 -2
View File
@@ -76,12 +76,13 @@ func (h *Handler) handleCreateFamilyName(msg *tgbotapi.Message) {
if strings.TrimSpace(chatName) == "" {
chatName = familyName
}
chatID := msg.Chat.ID
err = h.receiptApi.CreateFamily(ctx, domain.CreateFamilyRequest{
Name: familyName,
OwnerID: user.ID,
TelegramChatID: msg.Chat.ID,
TelegramChatName: chatName,
TelegramChatID: &chatID,
TelegramChatName: &chatName,
})
if err != nil {
log.Printf("failed to create family in api: %v", err)
+2 -1
View File
@@ -3,5 +3,6 @@ package domain
type AuthRequest struct {
TelegramId *string `json:"telegram_id"`
OTP *int64 `json:"otp"`
InitData *string `json:"init_data"`
InitData *string `json:"init_data"`
}
+15 -25
View File
@@ -6,8 +6,8 @@ type Family struct {
ID int64
Name string
OwnerID int64
TelegramChatID int64
TelegramChatName string
TelegramChatID *int64
TelegramChatName *string
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -29,37 +29,27 @@ type FamilyMember struct {
JoinedAt time.Time
}
type FamilyThread struct {
ID int64
FamilyID int64
Type string
Title string
TelegramTopicID int64
IsSystem bool
CreatedBy int64
CreatedAt time.Time
}
type CreateFamilyRequest struct {
Name string `json:"name"`
OwnerID int64 `json:"owner_id"`
TelegramChatID int64 `json:"telegram_chat_id"`
TelegramChatName string `json:"telegram_chat_name"`
Name string `json:"name"`
OwnerID int64 `json:"owner_id"`
TelegramChatID *int64 `json:"telegram_chat_id"`
TelegramChatName *string `json:"telegram_chat_name"`
}
type UpdateFamilyRequest struct {
Name *string `json:"name"`
TelegramChatName string `json:"telegram_chat_name"`
TelegramChatID *int64 `json:"telegram_chat_id"`
TelegramChatName *string `json:"telegram_chat_name"`
}
type FamilyResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
OwnerID int64 `json:"owner_id"`
TelegramChatID int64 `json:"telegram_chat_id"`
TelegramChatName string `json:"telegram_chat_name"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID int64 `json:"id"`
Name string `json:"name"`
OwnerID int64 `json:"owner_id"`
TelegramChatID *int64 `json:"telegram_chat_id"`
TelegramChatName *string `json:"telegram_chat_name"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (response *FamilyResponse) ModelToResponse(f *Family) FamilyResponse {
+14 -5
View File
@@ -3,6 +3,8 @@ package domain
import "time"
type Position struct {
ID int64 `json:"id"`
ReceiptID int64 `json:"receipt_id"`
SectionNumber string `json:"section_number"`
GTINCode string `json:"gtin_code"`
Tag string `json:"tag"`
@@ -25,6 +27,7 @@ type Position struct {
type Receipt struct {
ID int `json:"id"`
TransactionID *int64 `json:"transaction_id"`
Status int `json:"STATUS"`
AnotherAmount float64 `json:"another_amount"`
CashAmount float64 `json:"cash_amount"`
@@ -64,12 +67,18 @@ type Receipt struct {
}
type AddReceiptRequest struct {
Number string `json:"number" binding:"required,min=24,max=24"`
Date string `json:"date" binding:"required"`
Number string `json:"number" binding:"required,min=24,max=24"`
Date string `json:"date" binding:"required"`
FamilyID *int64 `json:"family_id"`
CreatedBy *int64 `json:"created_by"`
Type *string `json:"type"`
Category *string `json:"category"`
Description *string `json:"description"`
}
type AddReceiptResponse struct {
ID int32 `json:"id"`
Number string `json:"number"`
Date time.Time `json:"date"`
ID int32 `json:"id"`
Number string `json:"number"`
Date time.Time `json:"date"`
TransactionID *int64 `json:"transaction_id,omitempty"`
}
+48
View File
@@ -0,0 +1,48 @@
package domain
import "time"
type Transaction struct {
ID int64
FamilyID int64
Description *string
Type string
DateTime time.Time
Category string
Amount float64
CreatedAt time.Time
CreatedBy int64
ReceiptID *int64
}
type CreateTransactionRequest struct {
FamilyID int64
Description *string
Type string
DateTime time.Time
Category string
Amount float64
CreatedBy int64
ReceiptID *int64
}
type UpdateTransactionRequest struct {
Description *string
Type *string
DateTime *time.Time
Category *string
Amount *float64
ReceiptID *int64
DetachReceipt bool
}
type TransactionListFilter struct {
FamilyID *int64
CreatedBy *int64
Type *string
Category *string
DateFrom *time.Time
DateTo *time.Time
Limit int
Offset int
}
@@ -13,15 +13,20 @@ import (
"log"
"mime/multipart"
"net/http"
"strings"
"time"
)
type ReceiptService struct {
client *http.Client
repo repositories.ReceiptsRepository
client *http.Client
repo repositories.ReceiptsRepository
transactionRepo repositories.TransactionRepository
}
func NewReceiptService(repo repositories.ReceiptsRepository) *ReceiptService {
func NewReceiptService(
repo repositories.ReceiptsRepository,
transactionRepo repositories.TransactionRepository,
) *ReceiptService {
return &ReceiptService{
client: &http.Client{
Timeout: 60 * time.Second,
@@ -31,20 +36,20 @@ func NewReceiptService(repo repositories.ReceiptsRepository) *ReceiptService {
},
},
},
repo: repo,
repo: repo,
transactionRepo: transactionRepo,
}
}
func (s *ReceiptService) GetReceipt(
ctx context.Context,
date string,
number string,
req domain.AddReceiptRequest,
) (*domain.Receipt, error) {
url := "https://ch.info-center.by/ajax/check1.php"
var receipt domain.Receipt
body, contentType := buildMultipartBody(date, number)
req, err := http.NewRequestWithContext(
body, contentType := buildMultipartBody(req.Date, req.Number)
httpReq, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
url,
@@ -55,9 +60,9 @@ func (s *ReceiptService) GetReceipt(
return nil, err
}
req.Header.Set("Content-Type", contentType)
httpReq.Header.Set("Content-Type", contentType)
resp, err := s.client.Do(req)
resp, err := s.client.Do(httpReq)
if err != nil {
log.Println(err.Error())
return nil, err
@@ -119,9 +124,23 @@ func (s *ReceiptService) GetReceipt(
p.Surcharge, _ = utils.ParseFloat(p.SurchargeRaw)
}
if _, err := s.repo.Create(ctx, &receipt); err != nil {
receiptID, err := s.repo.Create(ctx, &receipt)
if err != nil {
return nil, err
}
receipt.ID = int(receiptID)
if s.shouldCreateTransaction(req) {
transaction, err := s.createTransactionForReceipt(ctx, &receipt, req, receiptID)
if err != nil {
if rollbackErr := s.repo.Delete(ctx, receiptID); rollbackErr != nil {
log.Printf("failed to rollback receipt %d after transaction error: %v", receiptID, rollbackErr)
}
return nil, err
}
receipt.TransactionID = &transaction.ID
}
return &receipt, nil
}
@@ -150,3 +169,61 @@ func parsePositions(raw string) ([]domain.Position, error) {
return positions, nil
}
func (s *ReceiptService) shouldCreateTransaction(req domain.AddReceiptRequest) bool {
return s.transactionRepo != nil && req.FamilyID != nil && req.CreatedBy != nil
}
func (s *ReceiptService) createTransactionForReceipt(
ctx context.Context,
receipt *domain.Receipt,
req domain.AddReceiptRequest,
receiptID int64,
) (*domain.Transaction, error) {
transactionType := "expense"
if req.Type != nil && strings.TrimSpace(*req.Type) != "" {
transactionType = strings.TrimSpace(*req.Type)
}
category := "receipt"
if req.Category != nil && strings.TrimSpace(*req.Category) != "" {
category = strings.TrimSpace(*req.Category)
}
description := buildReceiptTransactionDescription(receipt, req.Description)
transaction := &domain.Transaction{
FamilyID: *req.FamilyID,
Description: description,
Type: transactionType,
DateTime: receipt.IssuedAt,
Category: category,
Amount: receipt.TotalAmount,
CreatedBy: *req.CreatedBy,
ReceiptID: &receiptID,
}
if err := s.transactionRepo.Create(ctx, transaction); err != nil {
return nil, err
}
return transaction, nil
}
func buildReceiptTransactionDescription(receipt *domain.Receipt, explicit *string) *string {
if explicit != nil && strings.TrimSpace(*explicit) != "" {
value := strings.TrimSpace(*explicit)
return &value
}
if name := strings.TrimSpace(receipt.NameSPD); name != "" {
return &name
}
if number := strings.TrimSpace(receipt.ReceiptNumber); number != "" {
value := fmt.Sprintf("Receipt %s", number)
return &value
}
return nil
}
-1
View File
@@ -93,7 +93,6 @@ func (r *FamilySQLRepository) Update(ctx context.Context, family *domain.Family)
family.Name,
family.TelegramChatID,
family.TelegramChatName,
family.UpdatedAt,
family.ID,
).Scan(&family.UpdatedAt)
}
+12 -3
View File
@@ -36,7 +36,7 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
}
res, err := tx.ExecContext(ctx, `
INSERT INTO receipts (
receipt_number, ui, status, issued_at,
transaction_id, receipt_number, ui, status, issued_at,
total_amount, payment_amount, cash_amount,
another_amount, clearing_amount, margin,
currency, payment_type,
@@ -46,8 +46,9 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
kod_soato, oblast_soato, rayon_soato, selsovet_soato,
doc_num, skno_number, unp,
success
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
receipt.TransactionID,
receipt.ReceiptNumber,
receipt.UI,
receipt.Status,
@@ -144,6 +145,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
err := r.db.QueryRowContext(ctx, `
SELECT
id,
transaction_id,
receipt_number, ui, status, issued_at,
total_amount, payment_amount, cash_amount,
another_amount, clearing_amount, margin,
@@ -158,6 +160,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
WHERE id = ?
`, id).Scan(
&receipt.ID,
&receipt.TransactionID,
&receipt.ReceiptNumber,
&receipt.UI,
&receipt.Status,
@@ -205,6 +208,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
rows, err := r.db.QueryContext(ctx, `
SELECT
id, receipt_id,
section_number, gtin_code, product_name,
product_count, amount,
discount, surcharge,
@@ -219,6 +223,8 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
for rows.Next() {
var p domain.Position
if err := rows.Scan(
&p.ID,
&p.ReceiptID,
&p.SectionNumber,
&p.GTINCode,
&p.ProductName,
@@ -241,7 +247,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) ([]*domain.Receipt, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, receipt_number, issued_at, total_amount, currency
SELECT id, transaction_id, receipt_number, issued_at, total_amount, currency
FROM receipts
ORDER BY issued_at DESC
LIMIT ? OFFSET ?
@@ -257,6 +263,7 @@ func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) (
var rct domain.Receipt
if err := rows.Scan(
&rct.ID,
&rct.TransactionID,
&rct.ReceiptNumber,
&rct.IssuedAt,
&rct.TotalAmount,
@@ -280,11 +287,13 @@ func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Rece
_, err = tx.ExecContext(ctx, `
UPDATE receipts SET
transaction_id = ?,
issued_at = ?,
total_amount = ?,
currency = ?
WHERE id = ?
`,
receipt.TransactionID,
receipt.IssuedAt,
receipt.TotalAmount,
receipt.Currency,
+279
View File
@@ -0,0 +1,279 @@
package repositories
import (
"FamilyHub/src/domain"
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
var ErrReceiptNotFound = errors.New("receipt not found")
type TransactionRepository interface {
Create(ctx context.Context, transaction *domain.Transaction) error
GetByID(ctx context.Context, id int64) (*domain.Transaction, error)
List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error)
Update(ctx context.Context, transaction *domain.Transaction, syncReceipt bool) error
Delete(ctx context.Context, id int64) error
}
type TransactionsSQLRepository struct {
db *sql.DB
}
func NewTransactionsSQLRepository(db *sql.DB) *TransactionsSQLRepository {
return &TransactionsSQLRepository{db: db}
}
func (r *TransactionsSQLRepository) Create(ctx context.Context, transaction *domain.Transaction) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
query := `
INSERT INTO transactions
(family_id, description, type, datetime, category, amount, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at
`
if err := tx.QueryRowContext(
ctx,
query,
transaction.FamilyID,
transaction.Description,
transaction.Type,
transaction.DateTime,
transaction.Category,
transaction.Amount,
transaction.CreatedBy,
).Scan(&transaction.ID, &transaction.CreatedAt); err != nil {
return err
}
if err := r.rebindReceipt(ctx, tx, transaction.ID, transaction.ReceiptID); err != nil {
return err
}
return tx.Commit()
}
func (r *TransactionsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Transaction, error) {
query := `
SELECT
t.id,
t.family_id,
t.description,
t.type,
t.datetime,
t.category,
t.amount,
t.created_at,
t.created_by,
r.id
FROM transactions t
LEFT JOIN receipts r ON r.transaction_id = t.id
WHERE t.id = $1
`
var transaction domain.Transaction
err := r.db.QueryRowContext(ctx, query, id).Scan(
&transaction.ID,
&transaction.FamilyID,
&transaction.Description,
&transaction.Type,
&transaction.DateTime,
&transaction.Category,
&transaction.Amount,
&transaction.CreatedAt,
&transaction.CreatedBy,
&transaction.ReceiptID,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &transaction, nil
}
func (r *TransactionsSQLRepository) List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) {
var (
whereClauses []string
args []any
)
baseQuery := `
SELECT
t.id,
t.family_id,
t.description,
t.type,
t.datetime,
t.category,
t.amount,
t.created_at,
t.created_by,
r.id
FROM transactions t
LEFT JOIN receipts r ON r.transaction_id = t.id
`
appendFilter := func(condition string, value any) {
args = append(args, value)
whereClauses = append(whereClauses, fmt.Sprintf(condition, len(args)))
}
if filter.FamilyID != nil {
appendFilter("t.family_id = $%d", *filter.FamilyID)
}
if filter.CreatedBy != nil {
appendFilter("t.created_by = $%d", *filter.CreatedBy)
}
if filter.Type != nil {
appendFilter("t.type = $%d", *filter.Type)
}
if filter.Category != nil {
appendFilter("t.category = $%d", *filter.Category)
}
if filter.DateFrom != nil {
appendFilter("t.datetime >= $%d", *filter.DateFrom)
}
if filter.DateTo != nil {
appendFilter("t.datetime <= $%d", *filter.DateTo)
}
var queryBuilder strings.Builder
queryBuilder.WriteString(baseQuery)
if len(whereClauses) > 0 {
queryBuilder.WriteString(" WHERE ")
queryBuilder.WriteString(strings.Join(whereClauses, " AND "))
}
args = append(args, filter.Limit, filter.Offset)
queryBuilder.WriteString(fmt.Sprintf(" ORDER BY t.datetime DESC LIMIT $%d OFFSET $%d", len(args)-1, len(args)))
rows, err := r.db.QueryContext(ctx, queryBuilder.String(), args...)
if err != nil {
return nil, err
}
defer rows.Close()
var transactions []*domain.Transaction
for rows.Next() {
var transaction domain.Transaction
if err := rows.Scan(
&transaction.ID,
&transaction.FamilyID,
&transaction.Description,
&transaction.Type,
&transaction.DateTime,
&transaction.Category,
&transaction.Amount,
&transaction.CreatedAt,
&transaction.CreatedBy,
&transaction.ReceiptID,
); err != nil {
return nil, err
}
transactions = append(transactions, &transaction)
}
return transactions, rows.Err()
}
func (r *TransactionsSQLRepository) Update(ctx context.Context, transaction *domain.Transaction, syncReceipt bool) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
query := `
UPDATE transactions SET
description = $1,
type = $2,
datetime = $3,
category = $4,
amount = $5
WHERE id = $6
`
result, err := tx.ExecContext(
ctx,
query,
transaction.Description,
transaction.Type,
transaction.DateTime,
transaction.Category,
transaction.Amount,
transaction.ID,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return sql.ErrNoRows
}
if syncReceipt {
if err := r.rebindReceipt(ctx, tx, transaction.ID, transaction.ReceiptID); err != nil {
return err
}
}
return tx.Commit()
}
func (r *TransactionsSQLRepository) Delete(ctx context.Context, id int64) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM transactions WHERE id = $1`, id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return sql.ErrNoRows
}
return nil
}
func (r *TransactionsSQLRepository) rebindReceipt(ctx context.Context, tx *sql.Tx, transactionID int64, receiptID *int64) error {
if _, err := tx.ExecContext(ctx, `UPDATE receipts SET transaction_id = NULL WHERE transaction_id = $1`, transactionID); err != nil {
return err
}
if receiptID == nil {
return nil
}
result, err := tx.ExecContext(ctx, `UPDATE receipts SET transaction_id = $1 WHERE id = $2`, transactionID, *receiptID)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrReceiptNotFound
}
return nil
}