const mongoose = require('mongoose'), Schema = mongoose.Schema, mongoUtil = require('../helpers/mongo'), { Fields, UserTypes, APTypes, TrialTypes, ApplicationTypes, RefSources } = require('../helpers/constants'), { stripe } = require('../helpers/subscription_util'), User = require('../model/user'), BillPeriod = require('../model/bill_period'), Client = require('../model/client'), Product = require('../model/product'), Vehicle = require('../model/vehicle'), Crop = require('../model/crop'), { AddressSchema } = require('./common'), SubscriptionSchema = require('./subscription').subscriptionSchema, uniqid = require('uniqid'), validator = require('validator'), debug = require('debug')('agm:model-customer'); const RefSourceSchema = new Schema({ appTypes: { type: [{ type: String, enum: { values: Object.values(ApplicationTypes), message: '{VALUE} for \'appTypes\' is not supported' } }], default: [] }, refSources: { type: [{ type: String, enum: { values: Object.values(RefSources), message: '{VALUE} for \'refSources\' is not supported' } }], default: [] }, }, { _id: false }); const schema = new Schema({ contact: { type: String, required: false }, fax: { type: String, required: false }, premium: { type: Number, default: 0 }, billable: { type: Boolean, default: false }, country: { type: String, validate: [validator.isISO31661Alpha2, 'invalid_country_code'], trim: true, uppercase: true, required: [true, 'Country is required'] }, membership: { custId: String, // The Stripe Customer ID of this customer subscriptions: [SubscriptionSchema], trials: { type: { type: String, enum: { values: Object.values(TrialTypes), message: '{VALUE} for \'trials.type\' is not supported' } }, trialDays: { type: Number, default: 0 }, byDate: { type: Date, default: null }, startDate: { type: Date }, // The date the customer is offered a trial lastStartDate: { type: Date }, // Last trial start date lastEndDate: { type: Date }, // Last trial end date } }, /* To be removed in the future */ billAddress: { type: AddressSchema, required: false }, appInfo: { type: RefSourceSchema, required: false }, // Reference to the Partner collection (optional) partner: { type: Schema.Types.ObjectId, ref: 'Partner', required: false }, taxId: { type: String, default: '' }, selfSignup: { type: Boolean, default: false }, }, { strictQuery: false }); async function prepareDefault(cust, ses) { let defProd = new Product({ name: 'Water', type: APTypes.CARRIER, desc: 'Default carrier product', byPuid: cust._id }); await defProd.save({ session: ses }); } /** * Create a new customer (applicator) with all default data in atomic db session's transactions * @param {*} ses the db session. If ses is null, create a session and end when done * @param {boolean} endSesDone whether to end the session * @returns the new customer instance otherwise thrown exceptions */ schema.methods.createOne = async function (ses = null, endSesDone = false) { let _ses = ses; if (!ses) { _ses = await this.db.startSession(); endSesDone = true; } try { if (_ses.transaction && _ses.transaction.isActive) { await prepareDefault(this, _ses); await this.save({ session: _ses }); } else { await _ses.withTransaction(async () => { await prepareDefault(this, _ses); return this.save({ session: _ses }); }); } } finally { (endSesDone && _ses) && (await _ses.endSession()); } return this; } async function freeRelatedUserNames(session) { const users = await User.find({ parent: this._id }, '_id username', { lean: true }); if (!users.length) return; session && (session.startTransaction(mongoUtil.getTranOps())); try { // De-activate all related user in one batch const bulkDelOps = [] let updateDoc; for (let user of users) { updateDoc = { active: false }; if (user.username) updateDoc.username = user.username + '#' + uniqid(); bulkDelOps.push({ 'updateOne': { 'filter': { _id: user._id }, 'update': updateDoc } }); } await User.bulkWrite(bulkDelOps, { session }); } catch (error) { session && (session.abortTransaction()); throw error; } await mongoUtil.commitWithRetry(session); } async function deleteRelatedData(session) { const custUid = this._id; session && (session.startTransaction(mongoUtil.getTranOps())); try { // Entities await Product.deleteMany({ byPuid: custUid }).session(session); await Crop.deleteMany({ byPuid: custUid }).session(session); // Other accounts, related configs const users = await User.find({ parent: custUid, kind: { $nin: [UserTypes.CLIENT, UserTypes.DEVICE] } }); for (let j = 0; j < users.length; j++) { await users[j].remove({ session }); } } catch (error) { session && (session.abortTransaction()); throw error; } await mongoUtil.commitWithRetry(session); } async function deleteCustomerClients(session, markDeletedOnly) { const clients = await Client.find({ parent: this._id }); for (let i = 0; i < clients.length; i++) { await clients[i].removeFull(session, markDeletedOnly); } } async function deleteCustomerVehicles(session, markDeletedOnly) { const vehicles = await Vehicle.find({ parent: this._id }); for (let i = 0; i < vehicles.length; i++) { await vehicles[i].removeFull(session, markDeletedOnly); } } async function deleteSubscriptionInfo(session) { if (this.membership && this.membership?.custId) { // First, check if customer exists before attempting deletion if (stripe) { try { const stripeCust = await stripe.customers.retrieve(this.membership.custId); if (!stripeCust || stripeCust.deleted) { debug(`Stripe customer:'${this.membership.custId}' does not exist or has already been deleted`); return; // Customer does not exist, nothing to delete } // Customer exists, safe to delete await stripe.customers.del(this.membership.custId); } catch (stripeError) { if (stripeError.type === 'StripeInvalidRequestError' && stripeError.code === 'resource_missing') { debug(`Stripe customer:'${this.membership.custId}' not found - skipping deletion`); } else { // Log error but don't fail the entire operation debug(`Failed to delete Stripe customer:'${this.membership.custId}':`, stripeError.message); } } } // Clean up local Billing periods data await BillPeriod.deleteMany({ custId: this.membership.custId }, { session }); } } /** * Remove the Customer (applicator) with all related data in one atomic db session's transaction * @param {ClientSession} ses the db session. If sess is null, create a session and end when done * @param {boolean} markDeletedOnly Determine whether to just mark the item as deleted only. Default is true. * @param {boolean} endSesDone whether to end the session */ schema.methods.removeFull = async function (ses = null, markDeletedOnly = true, endSesDone = false) { let session = ses; if (!ses) { session = await this.db.startSession({ readPreference: { mode: "primary" } }); endSesDone = true; } try { // 1. Mark the customer as deleted if (!this[Fields.MARKED_DELETE]) await mongoUtil.runTransactionWithRetry(this.markAsDeleted.bind(this), session); if (markDeletedOnly) { // 1.1 Free related users await mongoUtil.runTransactionWithRetry(freeRelatedUserNames.bind(this), session); } else { // 2. Delete related common data: i.e.: entities await mongoUtil.runTransactionWithRetry(deleteRelatedData.bind(this), session); // 3. Delete related clients and jobs and job data. await deleteCustomerClients.call(this, session, markDeletedOnly); // 4. Delete related vehicles and data await deleteCustomerVehicles.call(this, session, markDeletedOnly); // 5. Delete subscriptions relalted info such as: Stripe Billing Period info await deleteSubscriptionInfo.call(this, session); // 5. Delete the customer await mongoUtil.runTransactionWithRetry(this.deleteMarked.bind(this), session); } } catch (error) { throw error; } finally { if (endSesDone && session) await session.endSession(); } } module.exports = User.discriminator(UserTypes.APP, schema, { clone: false });