Files
habitica/website/server/libs/payments/amazon.js
Matteo Pagliazzi 83aca20ce5 Fall Festival Gem Promo (#138)
* content: add gems blocks

* gemsBlocks: include ios and android identifiers

* wip: promo code

* split common constants into multiple files

* add second promo part

* geCurrentEvent, refactor promo

* fix lint

* fix exports, use world state api

* start adding world state tests

* remove console.log

* use gems block for purchases

* remove comments

* fix most unit tests

* restore comment

* fix lint

* prevent apple/google gift tests from breaking other tests when stub is not reset

* fix unit tests, clarify tests names

* iap: use gift object when gifting gems

* allow gift object with less data

* fix iap tests, remove findById stubs

* iap: require less data from the mobile apps

* apply discounts

* add missing worldState file

* fix lint

* add test event

* start removing 20 gems option for web

* start adding support for all gems packages on web

* fix unit tests for apple, stripe and google

* amazon: support all gems blocks

* paypal: support all gems blocks

* fix payments unit tests, add tests for getGemsBlock

* web: add gems plans with discounts, update stripe

* fix amazon and paypal clients, payments success modals

* amazon pay: disabled state

* update icons, start abstracting payments buttons

* begin redesign

* redesign gems modal

* fix buttons

* fix hover color for gems modal close icon

* add key to world state current event

* extend test event length

* implement gems modals designs

* early test fall2020

* fix header banner position

* add missing files

* use iso 8601 for dates, minor ui fixes

* fix time zones

* events: fix ISO8601 format

* fix css indentation

* start abstracting banners

* refactor payments buttons

* test spooky, fix group plans box

* implement gems promo banners, refactor banners, fixes

* fix lint

* fix dates

* remove unused i18n strings

* fix stripe integration test

* fix world state integration tests

* the current active event

* add missing unit tests

* add storybook story for payments buttons component

* fix typo

* fix(stripe): correct label when gifting subscriptions
2020-09-21 16:22:13 +02:00

379 lines
12 KiB
JavaScript

import amazonPayments from 'amazon-payments';
import nconf from 'nconf';
import moment from 'moment';
import cc from 'coupon-code';
import util from 'util';
import common from '../../../common';
import {
BadRequest,
NotAuthorized,
NotFound,
} from '../errors';
import payments from './payments'; // eslint-disable-line import/no-cycle
import { model as User } from '../../models/user'; // eslint-disable-line import/no-cycle
import { // eslint-disable-line import/no-cycle
model as Group,
basicFields as basicGroupFields,
} from '../../models/group';
import { model as Coupon } from '../../models/coupon';
import { getGemsBlock } from './gems'; // eslint-disable-line import/no-cycle
// TODO better handling of errors
const { i18n } = common;
const IS_SANDBOX = nconf.get('AMAZON_PAYMENTS_MODE') === 'sandbox';
const amzPayment = amazonPayments.connect({
environment: amazonPayments.Environment[IS_SANDBOX ? 'Sandbox' : 'Production'],
sellerId: nconf.get('AMAZON_PAYMENTS_SELLER_ID'),
mwsAccessKey: nconf.get('AMAZON_PAYMENTS_MWS_KEY'),
mwsSecretKey: nconf.get('AMAZON_PAYMENTS_MWS_SECRET'),
clientId: nconf.get('AMAZON_PAYMENTS_CLIENT_ID'),
});
const api = {};
api.constants = {
CURRENCY_CODE: 'USD',
SELLER_NOTE: 'Habitica Payment',
SELLER_NOTE_SUBSCRIPTION: 'Habitica Subscription',
SELLER_NOTE_ATHORIZATION_SUBSCRIPTION: 'Habitica Subscription Payment',
SELLER_NOTE_GROUP_NEW_MEMBER: 'Habitica Group Plan New Member',
STORE_NAME: 'Habitica',
GIFT_TYPE_GEMS: 'gems',
GIFT_TYPE_SUBSCRIPTION: 'subscription',
METHOD_BUY_GEMS: 'buyGems',
METHOD_CREATE_SUBSCRIPTION: 'createSubscription',
PAYMENT_METHOD: 'Amazon Payments',
PAYMENT_METHOD_GIFT: 'Amazon Payments (Gift)',
};
api.getTokenInfo = util.promisify(amzPayment.api.getTokenInfo).bind(amzPayment.api);
api.createOrderReferenceId = util
.promisify(amzPayment.offAmazonPayments.createOrderReferenceForId)
.bind(amzPayment.offAmazonPayments);
api.setOrderReferenceDetails = util
.promisify(amzPayment.offAmazonPayments.setOrderReferenceDetails)
.bind(amzPayment.offAmazonPayments);
api.confirmOrderReference = util
.promisify(amzPayment.offAmazonPayments.confirmOrderReference)
.bind(amzPayment.offAmazonPayments);
api.closeOrderReference = util
.promisify(amzPayment.offAmazonPayments.closeOrderReference)
.bind(amzPayment.offAmazonPayments);
api.setBillingAgreementDetails = util
.promisify(amzPayment.offAmazonPayments.setBillingAgreementDetails)
.bind(amzPayment.offAmazonPayments);
api.getBillingAgreementDetails = util
.promisify(amzPayment.offAmazonPayments.getBillingAgreementDetails)
.bind(amzPayment.offAmazonPayments);
api.confirmBillingAgreement = util
.promisify(amzPayment.offAmazonPayments.confirmBillingAgreement)
.bind(amzPayment.offAmazonPayments);
api.closeBillingAgreement = util
.promisify(amzPayment.offAmazonPayments.closeBillingAgreement)
.bind(amzPayment.offAmazonPayments);
api.authorizeOnBillingAgreement = function authorizeOnBillingAgreement (inputSet) {
return new Promise((resolve, reject) => {
amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => {
if (err) return reject(err);
if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful')));
return resolve(response);
});
});
};
api.authorize = function authorize (inputSet) {
return new Promise((resolve, reject) => {
amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => {
if (err) return reject(err);
if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful')));
return resolve(response);
});
});
};
/**
* Makes a purchase using Amazon Payment Lib
*
* @param options
* @param options.user The user object who is purchasing
* @param options.gift The gift details if any
* @param options.orderReferenceId The amazon orderReferenceId generated on the front end
* @param options.headers The request headers
*
* @return undefined
*/
api.checkout = async function checkout (options = {}) {
const {
gift, user, orderReferenceId, headers, gemsBlock: gemsBlockKey,
} = options;
let amount;
let gemsBlock;
if (gift) {
gift.member = await User.findById(gift.uuid).exec();
if (gift.type === this.constants.GIFT_TYPE_GEMS) {
if (gift.gems.amount <= 0) {
throw new BadRequest(i18n.t('badAmountOfGemsToPurchase'));
}
amount = gift.gems.amount / 4;
} else if (gift.type === this.constants.GIFT_TYPE_SUBSCRIPTION) {
amount = common.content.subscriptionBlocks[gift.subscription.key].price;
}
} else {
gemsBlock = getGemsBlock(gemsBlockKey);
amount = gemsBlock.price / 100;
}
if (!gift || gift.type === this.constants.GIFT_TYPE_GEMS) {
const receiver = gift ? gift.member : user;
const receiverCanGetGems = await receiver.canGetGems();
if (!receiverCanGetGems) throw new NotAuthorized(i18n.t('groupPolicyCannotGetGems', receiver.preferences.language));
}
await this.setOrderReferenceDetails({
AmazonOrderReferenceId: orderReferenceId,
OrderReferenceAttributes: {
OrderTotal: {
CurrencyCode: this.constants.CURRENCY_CODE,
Amount: amount,
},
SellerNote: this.constants.SELLER_NOTE,
SellerOrderAttributes: {
SellerOrderId: common.uuid(),
StoreName: this.constants.STORE_NAME,
},
},
});
await this.confirmOrderReference({ AmazonOrderReferenceId: orderReferenceId });
await this.authorize({
AmazonOrderReferenceId: orderReferenceId,
AuthorizationReferenceId: common.uuid().substring(0, 32),
AuthorizationAmount: {
CurrencyCode: this.constants.CURRENCY_CODE,
Amount: amount,
},
SellerAuthorizationNote: this.constants.SELLER_NOTE,
TransactionTimeout: 0,
CaptureNow: true,
});
await this.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId });
// execute payment
let method = this.constants.METHOD_BUY_GEMS;
const data = {
user,
paymentMethod: this.constants.PAYMENT_METHOD,
headers,
gemsBlock,
};
if (gift) {
if (gift.type === this.constants.GIFT_TYPE_SUBSCRIPTION) {
method = this.constants.METHOD_CREATE_SUBSCRIPTION;
}
gift.member = await User.findById(gift.uuid).exec();
data.gift = gift;
data.paymentMethod = this.constants.PAYMENT_METHOD_GIFT;
}
await payments[method](data);
};
/**
* Cancel an Amazon Subscription
*
* @param options
* @param options.user The user object who is canceling
* @param options.groupId The id of the group that is canceling
* @param options.headers The request headers
* @param options.cancellationReason A text string to control sending an email
*
* @return undefined
*/
api.cancelSubscription = async function cancelSubscription (options = {}) {
const {
user, groupId, headers, cancellationReason,
} = options;
let billingAgreementId;
let planId;
let lastBillingDate;
if (groupId) {
const groupFields = basicGroupFields.concat(' purchased');
const group = await Group.getGroup({
user, groupId, populateLeader: false, groupFields,
});
if (!group) {
throw new NotFound(i18n.t('groupNotFound'));
}
if (group.leader !== user._id) {
throw new NotAuthorized(i18n.t('onlyGroupLeaderCanManageSubscription'));
}
billingAgreementId = group.purchased.plan.customerId;
planId = group.purchased.plan.planId;
lastBillingDate = group.purchased.plan.lastBillingDate;
} else {
billingAgreementId = user.purchased.plan.customerId;
planId = user.purchased.plan.planId;
lastBillingDate = user.purchased.plan.lastBillingDate;
}
if (!billingAgreementId) throw new NotAuthorized(i18n.t('missingSubscription'));
const details = await this.getBillingAgreementDetails({
AmazonBillingAgreementId: billingAgreementId,
}).catch(err => err);
const badBAStates = ['Canceled', 'Closed', 'Suspended'];
if (
details
&& details.BillingAgreementDetails
&& details.BillingAgreementDetails.BillingAgreementStatus
&& badBAStates.indexOf(details.BillingAgreementDetails.BillingAgreementStatus.State) === -1
) {
await this.closeBillingAgreement({
AmazonBillingAgreementId: billingAgreementId,
});
}
const subscriptionBlock = common.content.subscriptionBlocks[planId];
const subscriptionLength = subscriptionBlock.months * 30;
await payments.cancelSubscription({
user,
groupId,
nextBill: moment(lastBillingDate).add({ days: subscriptionLength }),
paymentMethod: this.constants.PAYMENT_METHOD,
headers,
cancellationReason,
});
};
/**
* Allows for purchasing a user subscription or group subscription with Amazon
*
* @param options
* @param options.billingAgreementId The Amazon billingAgreementId generated on the front end
* @param options.user The user object who is purchasing
* @param options.sub The subscription data to purchase
* @param options.coupon The coupon to discount the sub
* @param options.groupId The id of the group purchasing a subscription
* @param options.headers The request headers to store on analytics
* @return undefined
*/
api.subscribe = async function subscribe (options) {
const {
billingAgreementId,
sub,
coupon,
user,
groupId,
headers,
} = options;
if (!sub) throw new BadRequest(i18n.t('missingSubscriptionCode'));
if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId');
if (sub.discount) { // apply discount
if (!coupon) throw new BadRequest(i18n.t('couponCodeRequired'));
const result = await Coupon.findOne({ _id: cc.validate(coupon), event: sub.key }).exec();
if (!result) throw new NotAuthorized(i18n.t('invalidCoupon'));
}
let amount = sub.price;
const leaderCount = 1;
const priceOfSingleMember = 3;
if (groupId) {
const groupFields = basicGroupFields.concat(' purchased');
const group = await Group.getGroup({
user, groupId, populateLeader: false, groupFields,
});
const membersCount = await group.getMemberCount();
amount = sub.price + (membersCount - leaderCount) * priceOfSingleMember;
}
await this.setBillingAgreementDetails({
AmazonBillingAgreementId: billingAgreementId,
BillingAgreementAttributes: {
SellerNote: this.constants.SELLER_NOTE_SUBSCRIPTION,
SellerBillingAgreementAttributes: {
SellerBillingAgreementId: common.uuid(),
StoreName: this.constants.STORE_NAME,
CustomInformation: this.constants.SELLER_NOTE_SUBSCRIPTION,
},
},
});
await this.confirmBillingAgreement({
AmazonBillingAgreementId: billingAgreementId,
});
await this.authorizeOnBillingAgreement({
AmazonBillingAgreementId: billingAgreementId,
AuthorizationReferenceId: common.uuid().substring(0, 32),
AuthorizationAmount: {
CurrencyCode: this.constants.CURRENCY_CODE,
Amount: amount,
},
SellerAuthorizationNote: this.constants.SELLER_NOTE_ATHORIZATION_SUBSCRIPTION,
TransactionTimeout: 0,
CaptureNow: true,
SellerNote: this.constants.SELLER_NOTE_ATHORIZATION_SUBSCRIPTION,
SellerOrderAttributes: {
SellerOrderId: common.uuid(),
StoreName: this.constants.STORE_NAME,
},
});
await payments.createSubscription({
user,
customerId: billingAgreementId,
paymentMethod: this.constants.PAYMENT_METHOD,
sub,
headers,
groupId,
});
};
api.chargeForAdditionalGroupMember = async function chargeForAdditionalGroupMember (group) {
// @TODO: Can we get this from the content plan?
const priceForNewMember = 3;
// @TODO: Prorate?
return this.authorizeOnBillingAgreement({
AmazonBillingAgreementId: group.purchased.plan.customerId,
AuthorizationReferenceId: common.uuid().substring(0, 32),
AuthorizationAmount: {
CurrencyCode: this.constants.CURRENCY_CODE,
Amount: priceForNewMember,
},
SellerAuthorizationNote: this.constants.SELLER_NOTE_GROUP_NEW_MEMBER,
TransactionTimeout: 0,
CaptureNow: true,
SellerNote: this.constants.SELLER_NOTE_GROUP_NEW_MEMBER,
SellerOrderAttributes: {
SellerOrderId: common.uuid(),
StoreName: this.constants.STORE_NAME,
},
});
};
export default api;