feat: Payment system with voucher cards.

feat: Design with inversion dependency injection architecture.
feat: Prettier http middleware.
feat: Re-write items categories with preferred accounts.
This commit is contained in:
Ahmed Bouhuolia
2020-08-27 20:39:55 +02:00
parent e23b8d9947
commit e4270dc039
63 changed files with 2567 additions and 462 deletions

View File

@@ -0,0 +1,31 @@
exports.up = function(knex) {
return knex.schema.createTable('subscription_plans', table => {
table.increments();
table.string('slug');
table.string('name');
table.string('desc');
table.boolean('active');
table.decimal('price').unsigned();
table.string('currency', 3);
table.decimal('trial_period').nullable();
table.string('trial_interval').nullable();
table.decimal('invoice_period').nullable();
table.string('invoice_interval').nullable();
table.integer('index').unsigned();
table.timestamps();
}).then(() => {
return knex.seed.run({
specific: 'seed_subscriptions_plans.js',
});
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('subscription_plans')
};

View File

@@ -0,0 +1,17 @@
exports.up = function(knex) {
return knex.schema.createTable('subscription_plan_features', table => {
table.increments();
table.integer('plan_id').unsigned();
table.string('slug');
table.string('name');
table.string('description');
table.timestamps();
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('subscription_plan_features');
};

View File

@@ -0,0 +1,25 @@
exports.up = function(knex) {
return knex.schema.createTable('subscription_plan_subscriptions', table => {
table.increments('id');
table.string('slug');
table.integer('plan_id').unsigned();
table.integer('tenant_id').unsigned();
table.dateTime('trial_started_at').nullable();
table.dateTime('trial_ends_at').nullable();
table.dateTime('starts_at').nullable();
table.dateTime('ends_at').nullable();
table.dateTime('cancels_at').nullable();
table.dateTime('canceled_at').nullable();
table.timestamps();
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('subscription_plan_subscriptions');
};

View File

@@ -0,0 +1,26 @@
exports.up = function(knex) {
return knex.schema.createTable('subscription_vouchers', table => {
table.increments();
table.string('voucher_code').unique();
table.integer('plan_id').unsigned();
table.integer('voucher_period').unsigned();
table.string('period_interval');
table.boolean('sent').defaultTo(false);
table.boolean('disabled').defaultTo(false);
table.boolean('used').defaultTo(false);
table.dateTime('sent_at');
table.dateTime('disabled_at');
table.dateTime('used_at');
table.timestamps();
})
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('subscription_vouchers');
};

View File

@@ -1,18 +0,0 @@
import { Model, mixin } from 'objection';
import SystemModel from '@/system/models/SystemModel';
import DateSession from '@/models/DateSession';
import UserSubscription from '@/services/Subscription/UserSubscription';
export default class SubscriptionLicense extends mixin(SystemModel, [DateSession, UserSubscription]) {
/**
* Table name.
*/
static get tableName() {
return 'subscription_licences';
}
markAsUsed() {
}
}

View File

@@ -1,10 +0,0 @@
import SystemModel from '@/system/models/SystemModel';
export default class SubscriptionUsage extends SystemModel {
/**
* Table name
*/
static get tableName() {
return 'subscriptions_usage';
}
}

View File

@@ -0,0 +1,94 @@
import { Model, mixin } from 'objection';
import SystemModel from '@/system/models/SystemModel';
import { PlanSubscription } from '..';
export default class Plan extends mixin(SystemModel) {
/**
* Table name.
*/
static get tableName() {
return 'subscription_plans';
}
/**
* Timestamps columns.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Defined virtual attributes.
*/
static get virtualAttributes() {
return ['isFree', 'hasTrial'];
}
/**
* Model modifiers.
*/
static get modifiers() {
return {
getFeatureBySlug(builder, featureSlug) {
builder.where('slug', featureSlug);
},
};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const PlanFeature = require('@/system/models/Subscriptions/PlanFeature');
return {
/**
* The plan may have many features.
*/
features: {
relation: Model.BelongsToOneRelation,
modelClass: PlanFeature.default,
join: {
from: 'subscriptions_plans.id',
to: 'subscriptions_plan_features.planId',
},
},
/**
* The plan may have many subscriptions.
*/
subscriptions: {
relation: Model.HasManyRelation,
modelClass: PlanSubscription.default,
join: {
from: 'subscription_plans.id',
to: 'subscription_plans.planId',
},
}
};
}
/**
* Check if plan is free.
* @return {boolean}
*/
isFree() {
return this.price <= 0;
}
/**
* Check if plan is paid.
* @return {boolean}
*/
isPaid() {
return !this.isFree();
}
/**
* Check if plan has trial.
* @return {boolean}
*/
hasTrial() {
return this.trialPeriod && this.trialInterval;
}
}

View File

@@ -0,0 +1,36 @@
import { Model, mixin } from 'objection';
import SystemModel from '@/system/models/SystemModel';
export default class PlanFeature extends mixin(SystemModel) {
/**
* Table name.
*/
static get tableName() {
return 'subscriptions.plan_features';
}
/**
* Timestamps columns.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const Plan = require('@/system/models/Subscriptions/Plan');
return {
plan: {
relation: Model.BelongsToOneRelation,
modelClass: Plan.default,
join: {
from: 'subscriptions.plan_features.planId',
to: 'subscriptions.plans.id',
},
},
};
}
}

View File

@@ -0,0 +1,170 @@
import { Model, mixin } from 'objection';
import SystemModel from '@/system/models/SystemModel';
import moment from 'moment';
import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod';
export default class PlanSubscription extends mixin(SystemModel) {
/**
* Table name.
*/
static get tableName() {
return 'subscription_plan_subscriptions';
}
/**
* Timestamps columns.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Defined virtual attributes.
*/
static get virtualAttributes() {
return ['active', 'inactive', 'ended', 'onTrial'];
}
/**
* Modifiers queries.
*/
static get modifiers() {
return {
activeSubscriptions(builder) {
const dateFormat = 'YYYY-MM-DD HH:mm:ss';
const now = moment().format(dateFormat);
builder.where('ends_at', '>', now);
builder.where('trial_ends_at', '>', now);
},
inactiveSubscriptions() {
builder.modify('endedTrial');
builder.modify('endedPeriod');
},
subscriptionBySlug(builder, subscriptionSlug) {
builder.where('slug', subscriptionSlug);
},
endedTrial(builder) {
const dateFormat = 'YYYY-MM-DD HH:mm:ss';
const endDate = moment().format(dateFormat);
builder.where('ends_at', '<=', endDate);
},
endedPeriod(builder) {
const dateFormat = 'YYYY-MM-DD HH:mm:ss';
const endDate = moment().format(dateFormat);
builder.where('trial_ends_at', '<=', endDate);
}
};
}
/**
* Relations mappings.
*/
static get relationMappings() {
const Tenant = require('@/system/Models/Tenant');
const Plan = require('@/system/Models/Subscriptions/Plan');
return {
/**
* Plan subscription belongs to tenant.
*/
tenant: {
relation: Model.BelongsToOneRelation,
modelClass: this.relationBindKnex(Tenant.default),
join: {
from: 'subscription_plan_subscriptions.tenantId',
to: 'tenants.id'
},
},
/**
* Plan description belongs to plan.
*/
plan: {
relation: Model.BelongsToOneRelation,
modelClass: this.relationBindKnex(Plan.default),
join: {
from: 'subscription_plan_subscriptions.planId',
to: 'subscription_plans.id',
},
},
};
}
/**
* Check if subscription is active.
* @return {Boolean}
*/
active() {
return !this.ended() || this.onTrial();
}
/**
* Check if subscription is inactive.
* @return {Boolean}
*/
inactive() {
return !this.active();
}
/**
* Check if subscription period has ended.
* @return {Boolean}
*/
ended() {
return this.endsAt ? moment().isAfter(this.endsAt) : false;
}
/**
* Check if subscription is currently on trial.
* @return {Boolean}
*/
onTrial() {
return this.trailEndsAt ? moment().isAfter(this.trailEndsAt) : false;
}
/**
* Set new period from the given details.
* @param {string} invoiceInterval
* @param {number} invoicePeriod
* @param {string} start
*
* @return {Object}
*/
setNewPeriod(invoiceInterval, invoicePeriod, start) {
let _invoiceInterval = invoiceInterval;
let _invoicePeriod = invoicePeriod;
if (!invoiceInterval) {
_invoiceInterval = this.plan.invoiceInterval;
}
if (!invoicePeriod) {
_invoicePeriod = this.plan.invoicePeriod;
}
const period = new SubscriptionPeriod(_invoiceInterval, _invoicePeriod, start);
const startsAt = period.getStartDate();
const endsAt = period.getEndDate();
return { startsAt, endsAt };
}
/**
* Renews subscription period.
* @Promise
*/
renew(plan) {
const { invoicePeriod, invoiceInterval } = plan;
const patch = { ...this.setNewPeriod(invoiceInterval, invoicePeriod) };
patch.cancelsAt = null;
patch.planId = plan.id;
return this.$query().patch(patch);
}
}

View File

@@ -0,0 +1,141 @@
import { Model, mixin } from 'objection';
import moment from 'moment';
import SystemModel from '@/system/models/SystemModel';
import { IVouchersFilter } from '@/interfaces';
export default class Voucher extends mixin(SystemModel) {
/**
* Table name.
*/
static get tableName() {
return 'subscription_vouchers';
}
/**
* Timestamps columns.
*/
get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Model modifiers.
*/
static get modifiers() {
return {
// Filters active vouchers.
filterActiveVoucher(query) {
query.where('disabled', false);
query.where('used', false);
query.where('sent', false);
},
// Find voucher by its code or id.
findByCodeOrId(query, id, code) {
if (id) {
query.where('id', id);
}
if (code) {
query.where('voucher_code', code);
}
},
// Filters vouchers list.
filter(builder, vouchersFilter: IVouchersFilter) {
if (vouchersFilter.active) {
builder.modify('filterActiveVoucher')
}
if (vouchersFilter.disabled) {
builder.where('disabled', true);
}
if (vouchersFilter.used) {
builder.where('used', true);
}
if (vouchersFilter.sent) {
builder.where('sent', true);
}
}
};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const Plan = require('@/system/models/Subscriptions/Plan');
return {
plan: {
relation: Model.BelongsToOneRelation,
modelClass: Plan.default,
join: {
from: 'subscription_vouchers.planId',
to: 'subscriptions_plans.id',
},
},
};
}
/**
* Deletes the given voucher code from the storage.
* @param {string} voucherCode
* @return {Promise}
*/
static deleteVoucher(voucherCode: string, viaAttribute: string = 'voucher_code') {
return this.query()
.where(viaAttribute, voucherCode)
.delete();
}
/**
* Marks the given voucher code as disabled on the storage.
* @param {string} voucherCode
* @return {Promise}
*/
static markVoucherAsDisabled(voucherCode: string, viaAttribute: string = 'voucher_code') {
return this.query()
.where(viaAttribute, voucherCode)
.patch({
disabled: true,
disabled_at: moment().toMySqlDateTime(),
});
}
/**
* Marks the given voucher code as sent on the storage.
* @param {string} voucherCode
*/
static markVoucherAsSent(voucherCode: string, viaAttribute: string = 'voucher_code') {
return this.query()
.where(viaAttribute, voucherCode)
.patch({
sent: true,
sent_at: moment().toMySqlDateTime(),
});
}
/**
* Marks the given voucher code as used on the storage.
* @param {string} voucherCode
* @return {Promise}
*/
static markVoucherAsUsed(voucherCode: string, viaAttribute: string = 'voucher_code') {
return this.query()
.where(viaAttribute, voucherCode)
.patch({
used: true,
used_at: moment().toMySqlDateTime()
});
}
/**
*
* @param {IIPlan} plan
* @return {boolean}
*/
isEqualPlanPeriod(plan) {
return (this.invoicePeriod === plan.invoiceInterval &&
voucher.voucherPeriod === voucher.periodInterval);
}
}

View File

@@ -1,10 +1,9 @@
import { Model, mixin } from 'objection';
import bcrypt from 'bcryptjs';
import SystemModel from '@/system/models/SystemModel';
import UserSubscription from '@/services/Subscription/UserSubscription';
export default class SystemUser extends mixin(SystemModel, [UserSubscription]) {
export default class SystemUser extends mixin(SystemModel) {
/**
* Table name.
*/
@@ -24,7 +23,6 @@ export default class SystemUser extends mixin(SystemModel, [UserSubscription]) {
*/
static get relationMappings() {
const Tenant = require('@/system/models/Tenant');
const SubscriptionUsage = require('@/system/models/SubscriptionUsage');
return {
tenant: {
@@ -35,15 +33,6 @@ export default class SystemUser extends mixin(SystemModel, [UserSubscription]) {
to: 'tenants.id',
},
},
subscriptionUsage: {
relation: Model.BelongsToOneRelation,
modelClass: SubscriptionUsage.default,
join: {
from: 'users.id',
to: 'subscriptions_usage.user_id',
}
},
};
}

View File

@@ -1,4 +1,6 @@
import BaseModel from '@/models/Model';
import { Model } from 'objection';
import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod';
export default class Tenant extends BaseModel {
/**
@@ -7,4 +9,63 @@ export default class Tenant extends BaseModel {
static get tableName() {
return 'tenants';
}
/**
* Query modifiers.
*/
static modifiers() {
return {
subscriptions(builder) {
builder.withGraphFetched('subscriptions');
},
};
}
/**
* Relations mappings.
*/
static get relationMappings() {
const PlanSubscription = require('./Subscriptions/PlanSubscription');
return {
subscriptions: {
relation: Model.HasManyRelation,
modelClass: this.relationBindKnex(PlanSubscription.default),
join: {
from: 'tenants.id',
to: 'subscription_plan_subscriptions.tenantId',
}
},
}
}
/**
* Retrieve the subscribed plans ids.
* @return {number[]}
*/
async subscribedPlansIds() {
const { subscriptions } = this;
return chain(subscriptions).map('planId').unq();
}
/**
* Records a new subscription for the associated tenant.
* @param {string} subscriptionSlug
* @param {IPlan} plan
*/
newSubscription(subscriptionSlug, plan) {
const trial = new SubscriptionPeriod(plan.trialInterval, plan.trialPeriod)
const period = new SubscriptionPeriod(plan.invoiceInterval, plan.invoicePeriod, trial.getEndDate());
return this.$relatedQuery('subscriptions').insert({
slug: subscriptionSlug,
planId: plan.id,
trialStartedAt: trial.getStartDate(),
trialEndsAt: trial.getEndDate(),
startsAt: period.getStartDate(),
endsAt: period.getEndDate(),
});
}
}

View File

@@ -0,0 +1,14 @@
import Plan from './Subscriptions/Plan';
import PlanFeature from './Subscriptions/PlanFeature';
import PlanSubscription from './Subscriptions/PlanSubscription';
import Voucher from './Subscriptions/Voucher';
import Tenant from './Tenant';
export {
Plan,
PlanFeature,
PlanSubscription,
Voucher,
Tenant,
}

View File

@@ -0,0 +1,26 @@
exports.seed = (knex) => {
// Deletes ALL existing entries
return knex('subscription_plans').del()
.then(() => {
// Inserts seed entries
return knex('subscription_plans').insert([
{
id: 1,
name: 'free',
slug: 'free',
price: 0,
active: true,
currency: 'LYD',
trial_period: 15,
trial_interval: 'days',
invoice_period: 3,
invoice_interval: 'month',
index: 1,
}
]);
});
};