Feature/add support for coupon duration (#743)
* Add support for coupon duration * Update changelog
This commit is contained in:
parent
b602e7690b
commit
99655604d9
@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for setting a duration in the coupon system
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Upgraded `ngx-skeleton-loader` from version `2.9.1` to `5.0.0`
|
- Upgraded `ngx-skeleton-loader` from version `2.9.1` to `5.0.0`
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isSameDay, parseISO } from 'date-fns';
|
import { isSameDay, parseISO } from 'date-fns';
|
||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportService {
|
export class ImportService {
|
||||||
|
@ -46,22 +46,25 @@ export class SubscriptionController {
|
|||||||
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
|
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
|
||||||
[];
|
[];
|
||||||
|
|
||||||
const isValid = coupons.some((coupon) => {
|
const coupon = coupons.find((currentCoupon) => {
|
||||||
return coupon.code === couponCode;
|
return currentCoupon.code === couponCode;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isValid) {
|
if (coupon === undefined) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
StatusCodes.BAD_REQUEST
|
StatusCodes.BAD_REQUEST
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.subscriptionService.createSubscription(this.request.user.id);
|
await this.subscriptionService.createSubscription({
|
||||||
|
duration: coupon.duration,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
// Destroy coupon
|
// Destroy coupon
|
||||||
coupons = coupons.filter((coupon) => {
|
coupons = coupons.filter((currentCoupon) => {
|
||||||
return coupon.code !== couponCode;
|
return currentCoupon.code !== couponCode;
|
||||||
});
|
});
|
||||||
await this.propertyService.put({
|
await this.propertyService.put({
|
||||||
key: PROPERTY_COUPONS,
|
key: PROPERTY_COUPONS,
|
||||||
@ -69,7 +72,7 @@ export class SubscriptionController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Logger.log(
|
Logger.log(
|
||||||
`Subscription for user '${this.request.user.id}' has been created with coupon`
|
`Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -2,8 +2,9 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Subscription, User } from '@prisma/client';
|
import { Subscription } from '@prisma/client';
|
||||||
import { addDays, isBefore } from 'date-fns';
|
import { addMilliseconds, isBefore } from 'date-fns';
|
||||||
|
import ms, { StringValue } from 'ms';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -64,13 +65,19 @@ export class SubscriptionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createSubscription(aUserId: string) {
|
public async createSubscription({
|
||||||
|
duration = '1 year',
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
duration?: StringValue;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
await this.prismaService.subscription.create({
|
await this.prismaService.subscription.create({
|
||||||
data: {
|
data: {
|
||||||
expiresAt: addDays(new Date(), 365),
|
expiresAt: addMilliseconds(new Date(), ms(duration)),
|
||||||
User: {
|
User: {
|
||||||
connect: {
|
connect: {
|
||||||
id: aUserId
|
id: userId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -83,7 +90,7 @@ export class SubscriptionService {
|
|||||||
aCheckoutSessionId
|
aCheckoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.createSubscription(session.client_reference_id);
|
await this.createSubscription({ userId: session.client_reference_id });
|
||||||
|
|
||||||
await this.stripe.customers.update(session.customer as string, {
|
await this.stripe.customers.update(session.customer as string, {
|
||||||
description: session.client_reference_id
|
description: session.client_reference_id
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
parseISO
|
parseISO
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { uniq } from 'lodash';
|
import { uniq } from 'lodash';
|
||||||
|
import { StringValue } from 'ms';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -29,6 +30,7 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './admin-overview.html'
|
templateUrl: './admin-overview.html'
|
||||||
})
|
})
|
||||||
export class AdminOverviewComponent implements OnDestroy, OnInit {
|
export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||||
|
public couponDuration: StringValue = '30 days';
|
||||||
public coupons: Coupon[];
|
public coupons: Coupon[];
|
||||||
public customCurrencies: string[];
|
public customCurrencies: string[];
|
||||||
public dataGatheringInProgress: boolean;
|
public dataGatheringInProgress: boolean;
|
||||||
@ -105,7 +107,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onAddCoupon() {
|
public onAddCoupon() {
|
||||||
const coupons = [...this.coupons, { code: this.generateCouponCode(16) }];
|
const coupons = [
|
||||||
|
...this.coupons,
|
||||||
|
{ code: this.generateCouponCode(16), duration: this.couponDuration }
|
||||||
|
];
|
||||||
this.putCoupons(coupons);
|
this.putCoupons(coupons);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,6 +123,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onChangeCouponDuration(aCouponDuration: StringValue) {
|
||||||
|
this.couponDuration = aCouponDuration;
|
||||||
|
}
|
||||||
|
|
||||||
public onDeleteCoupon(aCouponCode: string) {
|
public onDeleteCoupon(aCouponCode: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this coupon?');
|
const confirmation = confirm('Do you really want to delete this coupon?');
|
||||||
|
|
||||||
|
@ -156,11 +156,14 @@
|
|||||||
></mat-slide-toggle>
|
></mat-slide-toggle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="hasPermissionForSubscription" class="d-flex my-3">
|
<div
|
||||||
|
*ngIf="hasPermissionForSubscription"
|
||||||
|
class="d-flex my-3 subscription"
|
||||||
|
>
|
||||||
<div class="w-50" i18n>Coupons</div>
|
<div class="w-50" i18n>Coupons</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<div *ngFor="let coupon of coupons">
|
<div *ngFor="let coupon of coupons">
|
||||||
<span>{{ coupon.code }}</span>
|
<span>{{ coupon.code }} ({{ coupon.duration }})</span>
|
||||||
<button
|
<button
|
||||||
class="mini-icon mx-1 no-min-width px-2"
|
class="mini-icon mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
@ -170,10 +173,25 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<button color="primary" mat-flat-button (click)="onAddCoupon()">
|
<form #couponForm="ngForm">
|
||||||
<ion-icon class="mr-1" name="add-outline"></ion-icon>
|
<mat-form-field appearance="outline" class="mr-2">
|
||||||
<span i18n>Add Coupon</span>
|
<mat-select
|
||||||
</button>
|
name="duration"
|
||||||
|
[value]="couponDuration"
|
||||||
|
(selectionChange)="onChangeCouponDuration($event.value)"
|
||||||
|
>
|
||||||
|
<mat-option value="30 days">30 Days</mat-option>
|
||||||
|
<mat-option value="1 year">1 Year</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<button
|
||||||
|
color="primary"
|
||||||
|
mat-flat-button
|
||||||
|
(click)="onAddCoupon()"
|
||||||
|
>
|
||||||
|
<span i18n>Add</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
@ -12,11 +14,14 @@ import { AdminOverviewComponent } from './admin-overview.component';
|
|||||||
declarations: [AdminOverviewComponent],
|
declarations: [AdminOverviewComponent],
|
||||||
exports: [],
|
exports: [],
|
||||||
imports: [
|
imports: [
|
||||||
|
FormsModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
GfValueModule,
|
GfValueModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
MatSlideToggleModule
|
MatSelectModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
|
ReactiveFormsModule
|
||||||
],
|
],
|
||||||
providers: [CacheService],
|
providers: [CacheService],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
@ -20,4 +20,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subscription {
|
||||||
|
.mat-form-field {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { StringValue } from 'ms';
|
||||||
|
|
||||||
export interface Coupon {
|
export interface Coupon {
|
||||||
code: string;
|
code: string;
|
||||||
|
duration?: StringValue;
|
||||||
}
|
}
|
||||||
|
@ -100,6 +100,7 @@
|
|||||||
"http-status-codes": "2.2.0",
|
"http-status-codes": "2.2.0",
|
||||||
"ionicons": "5.5.1",
|
"ionicons": "5.5.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
|
"ms": "3.0.0-canary.1",
|
||||||
"ngx-device-detector": "3.0.0",
|
"ngx-device-detector": "3.0.0",
|
||||||
"ngx-markdown": "13.0.0",
|
"ngx-markdown": "13.0.0",
|
||||||
"ngx-skeleton-loader": "5.0.0",
|
"ngx-skeleton-loader": "5.0.0",
|
||||||
|
@ -13556,6 +13556,11 @@ ms@2.1.2:
|
|||||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
|
||||||
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
||||||
|
|
||||||
|
ms@3.0.0-canary.1:
|
||||||
|
version "3.0.0-canary.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/ms/-/ms-3.0.0-canary.1.tgz#c7b34fbce381492fd0b345d1cf56e14d67b77b80"
|
||||||
|
integrity sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g==
|
||||||
|
|
||||||
ms@^2.0.0, ms@^2.1.1:
|
ms@^2.0.0, ms@^2.1.1:
|
||||||
version "2.1.3"
|
version "2.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user