Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
898 changes: 898 additions & 0 deletions src/controllers/billController.ts

Large diffs are not rendered by default.

163 changes: 163 additions & 0 deletions src/database/migrations/20261010000000-add-bills.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"use strict";

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();

try {
const tables = await queryInterface.showAllTables({ transaction });

if (!tables.includes("Bills")) {
await queryInterface.createTable(
"Bills",
{
id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
organizerId: {
type: Sequelize.UUID,
allowNull: false,
references: { model: "Users", key: "id" },
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
recipientUserId: {
type: Sequelize.UUID,
allowNull: true,
references: { model: "Users", key: "id" },
onUpdate: "CASCADE",
onDelete: "SET NULL",
},
recipientOrganizationId: {
type: Sequelize.UUID,
allowNull: true,
references: { model: "Organizations", key: "id" },
onUpdate: "CASCADE",
onDelete: "SET NULL",
},
totalAmount: { type: Sequelize.DECIMAL(15, 2), allowNull: false },
currency: { type: Sequelize.STRING, allowNull: false, defaultValue: "RWF" },
title: { type: Sequelize.STRING, allowNull: false },
note: { type: Sequelize.STRING, allowNull: true },
status: {
type: Sequelize.ENUM("open", "completed", "cancelled"),
allowNull: false,
defaultValue: "open",
},
completedAt: { type: Sequelize.DATE, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
},
{ transaction }
);

await queryInterface.addIndex("Bills", ["organizerId"], {
name: "bills_organizer_idx",
transaction,
});
}

if (!tables.includes("BillShares")) {
await queryInterface.createTable(
"BillShares",
{
id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
billId: {
type: Sequelize.UUID,
allowNull: false,
references: { model: "Bills", key: "id" },
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
payerId: {
type: Sequelize.UUID,
allowNull: false,
references: { model: "Users", key: "id" },
onUpdate: "CASCADE",
onDelete: "CASCADE",
},
amount: { type: Sequelize.DECIMAL(15, 2), allowNull: false },
status: {
type: Sequelize.ENUM("pending", "paid", "covered", "declined", "closed", "cancelled"),
allowNull: false,
defaultValue: "pending",
},
paidByUserId: {
type: Sequelize.UUID,
allowNull: true,
references: { model: "Users", key: "id" },
onUpdate: "CASCADE",
onDelete: "SET NULL",
},
// Plain UUID (no FK): Transactions.billShareId already points the other way.
transactionId: { type: Sequelize.UUID, allowNull: true },
declineNote: { type: Sequelize.STRING, allowNull: true },
lastRemindedAt: { type: Sequelize.DATE, allowNull: true },
settledAt: { type: Sequelize.DATE, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
},
{ transaction }
);

await queryInterface.addIndex("BillShares", ["billId", "payerId"], {
unique: true,
name: "bill_shares_bill_payer_unique",
transaction,
});
await queryInterface.addIndex("BillShares", ["payerId"], {
name: "bill_shares_payer_idx",
transaction,
});
}

const transactionsColumns = await queryInterface.describeTable("Transactions", { transaction });
if (!transactionsColumns.billShareId) {
await queryInterface.addColumn(
"Transactions",
"billShareId",
{
type: Sequelize.UUID,
allowNull: true,
references: { model: "BillShares", key: "id" },
onUpdate: "CASCADE",
onDelete: "SET NULL",
},
{ transaction }
);
}

await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},

async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();

try {
const transactionsColumns = await queryInterface.describeTable("Transactions", { transaction });
if (transactionsColumns.billShareId) {
await queryInterface.removeColumn("Transactions", "billShareId", { transaction });
}

const tables = await queryInterface.showAllTables({ transaction });

if (tables.includes("BillShares")) {
await queryInterface.dropTable("BillShares", { transaction });
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_BillShares_status"', { transaction });
}

if (tables.includes("Bills")) {
await queryInterface.dropTable("Bills", { transaction });
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_Bills_status"', { transaction });
}

await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};
46 changes: 46 additions & 0 deletions src/database/models/bill.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// bill.model.ts
// A one-off "split payment": one recipient (a user OR an organization), a fixed total,
// and N BillShares - each an exact amount owed by one named payer, paid directly to the
// recipient's wallet (no pooling).
import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize";
import { BillAttributes, BillCreationAttributes } from "../../types/model";

class Bill extends Model<BillAttributes, BillCreationAttributes> {
public id!: string;
public organizerId!: string;
public recipientUserId?: string | null;
public recipientOrganizationId?: string | null;
public totalAmount!: number;
public currency!: string;
public title!: string;
public note?: string | null;
public status!: "open" | "completed" | "cancelled";
public completedAt?: Date | null;
public createdAt?: Date;
public updatedAt?: Date;
}

const bill_model = (sequelize: Sequelize) => {
Bill.init(
{
id: { type: DataTypes.UUID, defaultValue: UUIDV4, primaryKey: true },
organizerId: { type: DataTypes.UUID, allowNull: false },
recipientUserId: { type: DataTypes.UUID, allowNull: true },
recipientOrganizationId: { type: DataTypes.UUID, allowNull: true },
totalAmount: { type: DataTypes.DECIMAL(15, 2), allowNull: false },
currency: { type: DataTypes.STRING, defaultValue: "RWF" },
title: { type: DataTypes.STRING, allowNull: false },
note: { type: DataTypes.STRING, allowNull: true },
status: {
type: DataTypes.ENUM("open", "completed", "cancelled"),
defaultValue: "open",
},
completedAt: { type: DataTypes.DATE, allowNull: true },
},
{ sequelize, tableName: "Bills" }
);

return Bill;
};

export default bill_model;
52 changes: 52 additions & 0 deletions src/database/models/billShare.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// billShare.model.ts
import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize";
import {
BillShareAttributes,
BillShareCreationAttributes,
BillShareStatus,
} from "../../types/model";

class BillShare extends Model<BillShareAttributes, BillShareCreationAttributes> {
public id!: string;
public billId!: string;
public payerId!: string;
public amount!: number;
public status!: BillShareStatus;
public paidByUserId?: string | null;
public transactionId?: string | null;
public declineNote?: string | null;
public lastRemindedAt?: Date | null;
public settledAt?: Date | null;
public createdAt?: Date;
public updatedAt?: Date;
}

const billShare_model = (sequelize: Sequelize) => {
BillShare.init(
{
id: { type: DataTypes.UUID, defaultValue: UUIDV4, primaryKey: true },
billId: { type: DataTypes.UUID, allowNull: false },
payerId: { type: DataTypes.UUID, allowNull: false },
amount: { type: DataTypes.DECIMAL(15, 2), allowNull: false },
status: {
type: DataTypes.ENUM("pending", "paid", "covered", "declined", "closed", "cancelled"),
defaultValue: "pending",
},
// Who actually paid: the payer themselves for "paid", someone else for "covered".
paidByUserId: { type: DataTypes.UUID, allowNull: true },
transactionId: { type: DataTypes.UUID, allowNull: true },
declineNote: { type: DataTypes.STRING, allowNull: true },
lastRemindedAt: { type: DataTypes.DATE, allowNull: true },
settledAt: { type: DataTypes.DATE, allowNull: true },
},
{
sequelize,
tableName: "BillShares",
indexes: [{ unique: true, fields: ["billId", "payerId"] }],
}
);

return BillShare;
};

export default billShare_model;
24 changes: 24 additions & 0 deletions src/database/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import sharedWalletWithdrawal_model from "./sharedWalletWithdrawal.model";
import sharedWalletWithdrawalVote_model from "./sharedWalletWithdrawalVote.model";
import sharedWalletPolicyChange_model from "./sharedWalletPolicyChange.model";
import sharedWalletPolicyChangeVote_model from "./sharedWalletPolicyChangeVote.model";
import bill_model from "./bill.model";
import billShare_model from "./billShare.model";
import transferBatch_model from "./transferBatch.model";
import scheduledTransferBatch_model from "./scheduledTransferBatch.model";
import galleryItem_model from "./galleryItem.model";
Expand Down Expand Up @@ -124,6 +126,10 @@ const Models = (sequelize: Sequelize) => {
const SharedWalletPolicyChange = sharedWalletPolicyChange_model(sequelize);
const SharedWalletPolicyChangeVote = sharedWalletPolicyChangeVote_model(sequelize);

// Bill (split payment) models
const Bill = bill_model(sequelize);
const BillShare = billShare_model(sequelize);

// Transfer Batch model
const TransferBatch = transferBatch_model(sequelize);

Expand Down Expand Up @@ -451,6 +457,22 @@ const Models = (sequelize: Sequelize) => {
User.hasMany(SharedWalletPolicyChangeVote, { foreignKey: "userId", as: "sharedWalletPolicyChangeVotes" });
SharedWalletPolicyChangeVote.belongsTo(User, { foreignKey: "userId", as: "user" });

// Bills (split payments)
Bill.hasMany(BillShare, { foreignKey: "billId", as: "shares" });
BillShare.belongsTo(Bill, { foreignKey: "billId", as: "bill" });

User.hasMany(Bill, { foreignKey: "organizerId", as: "organizedBills" });
Bill.belongsTo(User, { foreignKey: "organizerId", as: "organizer" });
Bill.belongsTo(User, { foreignKey: "recipientUserId", as: "recipientUser" });
Bill.belongsTo(Organization, { foreignKey: "recipientOrganizationId", as: "recipientOrganization" });

User.hasMany(BillShare, { foreignKey: "payerId", as: "billShares" });
BillShare.belongsTo(User, { foreignKey: "payerId", as: "payer" });
BillShare.belongsTo(User, { foreignKey: "paidByUserId", as: "paidBy" });

BillShare.hasOne(Transaction, { foreignKey: "billShareId", as: "transaction" });
Transaction.belongsTo(BillShare, { foreignKey: "billShareId", as: "billShare" });

// Transfer Batches
User.hasMany(TransferBatch, { foreignKey: "createdByUserId", as: "createdTransferBatches" });
TransferBatch.belongsTo(User, { foreignKey: "createdByUserId", as: "createdBy" });
Expand Down Expand Up @@ -828,6 +850,8 @@ const Models = (sequelize: Sequelize) => {
SharedWalletWithdrawalVote,
SharedWalletPolicyChange,
SharedWalletPolicyChangeVote,
Bill,
BillShare,
TransferBatch,
ScheduledTransferBatch,
OutsideMessage,
Expand Down
9 changes: 9 additions & 0 deletions src/database/models/transaction.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class Transaction extends Model<
public batchId?: string | null;
public escrowId?: string | null;
public sharedWalletWithdrawalId?: string | null;
public billShareId?: string | null;
}

const Transaction_model = (sequelize: Sequelize) => {
Expand Down Expand Up @@ -130,6 +131,14 @@ const Transaction_model = (sequelize: Sequelize) => {
key: "id",
},
},
billShareId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: "BillShares",
key: "id",
},
},
},
{ sequelize, tableName: "Transactions" }
);
Expand Down
24 changes: 24 additions & 0 deletions src/routes/bill.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { RequestHandler, Router } from "express";
import { authenticate } from "../middleware/auth.middleware";
import * as billController from "../controllers/billController";

const router = Router();
router.use(authenticate);

// Creation + reading
router.post("/", billController.createBill as RequestHandler);
router.get("/", billController.listMyBills as RequestHandler);
router.get("/:id", billController.getBillById as RequestHandler);

// Share actions
router.post("/:id/shares/:shareId/pay", billController.payShare as RequestHandler);
router.post("/:id/shares/:shareId/decline", billController.declineShare as RequestHandler);
router.post("/:id/shares/:shareId/cover", billController.coverShare as RequestHandler);
router.post("/:id/shares/:shareId/reassign", billController.reassignShare as RequestHandler);
router.post("/:id/shares/:shareId/close", billController.closeShare as RequestHandler);
router.post("/:id/shares/:shareId/remind", billController.remindShare as RequestHandler);

// Whole-bill action
router.post("/:id/cancel", billController.cancelBill as RequestHandler);

export default router;
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import profileRouter from "./profile.routes";
import groupRouter from "./group.routes";
import groupContributionRouter from "./groupContribution.routes";
import sharedWalletRouter from "./sharedWallet.routes";
import billRouter from "./bill.routes";
import userContributionsRouter from "./userContributions.routes";
import publicContributionRouter from "./publicContribution.routes";
import notificationRouter from "./notification.routes";
Expand Down Expand Up @@ -68,6 +69,7 @@ router.use("/contact-invitations", contactInvitationRouter);
router.use("/groups", groupRouter);
router.use("/groups/:groupId/contributions", groupContributionRouter);
router.use("/shared-wallets", sharedWalletRouter);
router.use("/bills", billRouter);
router.use("/contributions", userContributionsRouter);
router.use("/public-contributions", publicContributionRouter);
router.use("/notifications", notificationRouter);
Expand Down
Loading
Loading