Skip to content
Permalink
Browse files

Merge pull request #1112 from mozilla/pb/981-deferred-cancellation

#1112
r=lmorchard
  • Loading branch information...
philbooth committed May 15, 2019
2 parents 53721bc + 4ee7184 commit fd60838e89593497721d148b26f828b703ded8f5
@@ -269,6 +269,13 @@ function createServer(db) {
req.params.subscriptionId
))
)
api.post('/account/:uid/subscriptions/:subscriptionId/cancel',
op(req => db.cancelAccountSubscription(
req.params.uid,
req.params.subscriptionId,
req.body.cancelledAt
))
)
api.get('/account/:id/subscriptions', withIdAndBody(db.fetchAccountSubscriptions))

api.get(
@@ -2722,6 +2722,47 @@ module.exports = function (config, DB) {
)
})

it('should cancel subscriptions', async () => {
await db.createAccountSubscription(account.uid, subscriptionIds[18], 'prod0', Date.now())
await db.createAccountSubscription(account.uid, subscriptionIds[19], 'prod1', Date.now())

const cancelledAt = Date.now()
await db.cancelAccountSubscription(account.uid, subscriptionIds[19], cancelledAt)

const subscriptions = await db.fetchAccountSubscriptions(account.uid)

assert.lengthOf(subscriptions, 2)
assert.deepEqual(
pickSet(subscriptions, 'subscriptionId'),
new Set([ subscriptionIds[18], subscriptionIds[19] ])
)
assert.deepEqual(
pickSet(subscriptions, 'cancelledAt'),
new Set([ null, cancelledAt ])
)
})

it('should fail to cancel a non-existent subscription', async () => {
try {
await db.cancelAccountSubscription(account.uid, subscriptionIds[20], Date.now())
assert.fail()
} catch (err) {
assert.equal(err.errno, 116)
}
})

it('should fail to cancel a cancelled subscription', async () => {
await db.createAccountSubscription(account.uid, subscriptionIds[21], 'prod0', Date.now())
await db.cancelAccountSubscription(account.uid, subscriptionIds[21], Date.now())

try {
await db.cancelAccountSubscription(account.uid, subscriptionIds[21], Date.now())
assert.fail()
} catch (err) {
assert.equal(err.errno, 116)
}
})

it('should support fetching one subscription', async () => {
await db.createAccountSubscription(account.uid, subscriptionIds[9], 'prod7', Date.now())
const result = await db.getAccountSubscription(account.uid, subscriptionIds[9])
@@ -110,7 +110,8 @@ The following datatypes are used throughout this document:
* createAccountSubscription : `PUT /account/:id/subscriptions/:subscriptionId`
* fetchAccountSubscriptions : `GET /account/:id/subscriptions`
* getAccountSubscription : `GET /account/:id/subscriptions/:subscriptionId`
* deleteAccountSubscriptions : `DELETE /account/:id/subscriptions/:subscriptionId`
* deleteAccountSubscription : `DELETE /account/:id/subscriptions/:subscriptionId`
* cancelAccountSubscription : `POST /account/:id/subscriptions/:subscriptionId/cancel`

## Ping : `GET /`

@@ -1992,6 +1993,9 @@ curl \
* Method : `POST`
* Path : `/totp/<uid>/update`
* `uid` : hex
* Params:
* verified : boolean
* enable : boolean
### Response
@@ -2024,7 +2028,7 @@ curl \
-H "Content-Type: application/json" \
-d '{
"count" : 8
}'
}' \
http://localhost:8000/account/1234567890ab/recoveryCodes
```
@@ -2033,7 +2037,8 @@ curl \
* Method : `POST`
* Path : `/account/<uid>/recoveryCodes`
* `uid` : hex
*
* Params:
* count : int
### Response
@@ -2404,7 +2409,7 @@ Content-Length: 2
* Content-Type : `application/json`
* Body : `{"code":"InternalError","message":"..."}`
## deleteAccountSubscriptions : `DELETE /account/:id/subscriptions/:subscriptionId`
## deleteAccountSubscription : `DELETE /account/:id/subscriptions/:subscriptionId`
### Example
@@ -2419,7 +2424,7 @@ curl \
### Request
* Method : `DELETE`
* Path : `/account/<uid>/subscriptions`
* Path : `/account/<uid>/subscriptions/<subscriptionId>`
* `uid` : hex
### Response
@@ -2440,3 +2445,42 @@ Content-Length: 2
* Content-Type : `application/json`
* Body : `{"code":"InternalError","message":"..."}`
## cancelAccountSubscription : `POST /account/:id/subscriptions/:subscriptionId/cancel`
### Example
```
curl \
-v \
-X DELETE \
-H "Content-Type: application/json" \
-d '{"cancelledAt":1557844225547}' \
http://localhost:8000/account/6044486dd15b42e08b1fb9167415b9ac/subscriptions/sub8675309/cancel
```
### Request
* Method : `POST`
* Path : `/account/<uid>/subscriptions/<subscriptionId>`
* `uid` : hex
* `subscriptionId` : string255
* Params:
* `cancelledAt`: uint64
### Response
```
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 2

{}
```
* Status Code : `200 OK`
* Content-Type : `application/json`
* Body : `{}`
* Status Code : `500 Internal Server Error`
* Conditions: if something goes wrong on the server
* Content-Type : `application/json`
* Body : `{"code":"InternalError","message":"..."}`
@@ -80,6 +80,7 @@ There are a number of methods that a DB storage backend should implement:
* .fetchAccountSubscriptions(uid)
* .getAccountSubscription(uid, subscriptionId)
* .deleteAccountSubscription(uid, subscriptionId)
* .cancelAccountSubscription(uid, subscriptionId, cancelledAt)
* General
* .ping()
* .close()
@@ -1107,3 +1108,25 @@ Returns:
* Empty object `{}`
* Rejects with:
* Any error from the underlying storage system (wrapped in `error.wrap()`)

## .cancelAccountSubscription(uid, subscriptionId, cancelledAt)

Cancel a product subscription for this user.
A cancelled subscription is still active,
but will be deleted later when it expires.

Parameters:

* `uid` (Buffer16):
The uid of the owning account
* `subscriptionId` (String):
The subscription ID from the upstream payment system
* `cancelledAt` (number):
Cancellation timestamp, epoch-milliseconds

Returns:

* Resolves with:
* Empty object `{}`
* Rejects with:
* Any error from the underlying storage system (wrapped in `error.wrap()`)
@@ -1517,7 +1517,8 @@ module.exports = function (log, error) {
uid,
subscriptionId,
productName,
createdAt
createdAt,
cancelledAt: null
}
return {}
}
@@ -1559,6 +1560,29 @@ module.exports = function (log, error) {
return {}
}

Memory.prototype.cancelAccountSubscription = async function (uid, subscriptionId, cancelledAt) {
uid = uid.toString('hex')

// Ensure user account exists
await getAccountByUid(uid)

const cancelled = Object.values(accountSubscriptions)
.some(subscription => {
if (subscription.uid === uid && subscription.subscriptionId === subscriptionId && ! subscription.cancelledAt) {
subscription.cancelledAt = cancelledAt
return true
}

return false
})

if (! cancelled) {
throw error.notFound()
}

return {}
}

// UTILITY FUNCTIONS

Memory.prototype.ping = function () {
@@ -1635,7 +1635,7 @@ module.exports = function (log, error) {
return this.readFirstResult(GET_ACCOUNT_SUBSCRIPTION, [ uid, subscriptionId ])
}

const FETCH_ACCOUNT_SUBSCRIPTIONS = 'CALL fetchAccountSubscriptions_1(?)'
const FETCH_ACCOUNT_SUBSCRIPTIONS = 'CALL fetchAccountSubscriptions_2(?)'
MySql.prototype.fetchAccountSubscriptions = function (uid) {
return this.readAllResults(FETCH_ACCOUNT_SUBSCRIPTIONS, [ uid ])
}
@@ -1646,5 +1646,17 @@ module.exports = function (log, error) {
.then(result => ({}))
}

const CANCEL_ACCOUNT_SUBSCRIPTION = 'CALL cancelAccountSubscription_1(?,?,?)'
MySql.prototype.cancelAccountSubscription = async function (uid, subscriptionId, cancelledAt) {
const result = await this.read(CANCEL_ACCOUNT_SUBSCRIPTION, [ uid, subscriptionId, cancelledAt ])

if (result.affectedRows === 0) {
log.error('MySql.cancelAccountSubscription.notUpdated', { result })
throw error.notFound()
}

return {}
}

return MySql
}
@@ -4,4 +4,4 @@

// The expected patch level of the database. Update if you add a new
// patch in the ./schema/ directory.
module.exports.level = 98
module.exports.level = 99
@@ -0,0 +1,42 @@
SET NAMES utf8mb4 COLLATE utf8mb4_bin;

CALL assertPatchLevel('98');

-- Add a `cancelledAt` column to `accountSubscriptions` and a
-- `cancelAccountSubscription_1` stored procedure for setting it.

ALTER TABLE `accountSubscriptions`
ADD COLUMN `cancelledAt` BIGINT UNSIGNED DEFAULT NULL,
ALGORITHM = INPLACE, LOCK = NONE;

CREATE PROCEDURE `cancelAccountSubscription_1` (
IN uidArg BINARY(16),
IN subscriptionIdArg VARCHAR(191),
IN cancelledAtArg BIGINT UNSIGNED
)
BEGIN
UPDATE accountSubscriptions
SET cancelledAt = cancelledAtArg
WHERE uid = uidArg
AND subscriptionId = subscriptionIdArg
AND cancelledAt IS NULL;
END;

CREATE PROCEDURE `fetchAccountSubscriptions_2` (
IN uidArg BINARY(16)
)
BEGIN
SELECT
asi.uid,
asi.subscriptionId,
asi.productName,
asi.createdAt,
asi.cancelledAt
FROM accountSubscriptions asi
WHERE
asi.uid = uidArg
ORDER BY
asi.createdAt asc;
END;

UPDATE dbMetadata SET value = '99' WHERE name = 'schema-patch-level';
@@ -0,0 +1,10 @@
--SET NAMES utf8mb4 COLLATE utf8mb4_bin;

--DROP PROCEDURE `fetchAccountSubscriptions_2`;
--DROP PROCEDURE `cancelAccountSubscription_1`;

--ALTER TABLE `accountSubscriptions`
--DROP COLUMN `cancelledAt`,
--ALGORITHM = INPLACE, LOCK = NONE;

--UPDATE dbMetadata SET value = '98' WHERE name = 'schema-patch-level';
@@ -1393,6 +1393,15 @@ module.exports = (
return this.pool.del(SAFE_URLS.deleteAccountSubscription, { uid, subscriptionId });
};

SAFE_URLS.cancelAccountSubscription = new SafeUrl(
'/account/:uid/subscriptions/:subscriptionId/cancel',
'db.cancelAccountSubscription'
);
DB.prototype.cancelAccountSubscription = function (uid, subscriptionId, cancelledAt) {
log.trace('DB.deleteAccountSubscription', { uid, subscriptionId, cancelledAt });
return this.pool.del(SAFE_URLS.deleteAccountSubscription, { uid, subscriptionId }, { cancelledAt });
};

SAFE_URLS.fetchAccountSubscriptions = new SafeUrl(
'/account/:uid/subscriptions',
'db.fetchAccountSubscriptions'
@@ -90,6 +90,7 @@ const ERRNO = {
UNKNOWN_SUBSCRIPTION: 177,
UNKNOWN_SUBSCRIPTION_PLAN: 178,
REJECTED_SUBSCRIPTION_PAYMENT_TOKEN: 179,
SUBSCRIPTION_ALREADY_CANCELLED: 180,

SERVER_BUSY: 201,
FEATURE_NOT_ENABLED: 202,
@@ -1082,6 +1083,15 @@ AppError.rejectedSubscriptionPaymentToken = (token) => {
});
};

AppError.subscriptionAlreadyCancelled = (token) => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.SUBSCRIPTION_ALREADY_CANCELLED,
message: 'Subscription has already been cancelled'
});
};

AppError.insufficientACRValues = (foundValue) => {
return new AppError({
code: 400,
@@ -216,7 +216,14 @@ module.exports = (log, db, config, customs, push, oauthdb, subhub) => {
}

await subhub.cancelSubscription(uid, subscriptionId);
await db.deleteAccountSubscription(uid, subscriptionId);

try {
await db.cancelAccountSubscription(uid, subscriptionId, Date.now());
} catch (err) {
if (err.statusCode === 404 && err.errno === 116) {
throw error.subscriptionAlreadyCancelled();
}
}

const devices = await request.app.devices;
await push.notifyProfileUpdated(uid, devices);
Oops, something went wrong.

0 comments on commit fd60838

Please sign in to comment.
You can’t perform that action at this time.