0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-15 03:01:37 -05:00

🐛 Fixed missing subscription attribution on free to paid upgrade (#21846)

closes https://linear.app/ghost/issue/ENG-1561

- problem: When a free member upgraded to paid, we sometimes did not
capture the subscription attribution data

- cause:
    - after checkout, Stripe sends `customer.subscription.created`,
`customer.subscription.updated` and `checkout.session.completed` webhook
events
    - we want to create a subscription in our database based on the
`checkout.session.completed` event, as it contains additional data (e.g.
subscription attribution data)
    - but, we were sometimes creating a subscription based on a
`customer.subscription.*` event during free → paid upgrades, which did
not contain subscription attribution data

- solution: we now ignore `customer.subscription.*` events until a
member and its related subscription have been created in the database
first, by the `checkout.session.completed` event
This commit is contained in:
Sag 2024-12-11 10:49:28 +08:00 committed by GitHub
parent dd9d3a6f2e
commit bd20ad3adb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 733 additions and 73 deletions

View file

@ -48,6 +48,54 @@ Object {
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with author attribution 3: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Any<Object>,
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"email_suppression": Object {
"info": null,
"suppressed": false,
},
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": false,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with author attribution 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2384",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with deleted post attribution 1: [body] 1`] = `
Object {
"members": Array [
@ -96,6 +144,54 @@ Object {
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with deleted post attribution 3: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Any<Object>,
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"email_suppression": Object {
"info": null,
"suppressed": false,
},
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": false,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with deleted post attribution 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2391",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with empty attribution object 1: [body] 1`] = `
Object {
"members": Array [
@ -144,6 +240,54 @@ Object {
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with empty attribution object 3: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Any<Object>,
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"email_suppression": Object {
"info": null,
"suppressed": false,
},
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": false,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with empty attribution object 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2335",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with page attribution 1: [body] 1`] = `
Object {
"members": Array [
@ -192,6 +336,54 @@ Object {
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with page attribution 3: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Any<Object>,
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"email_suppression": Object {
"info": null,
"suppressed": false,
},
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": false,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with page attribution 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2415",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with post attribution 1: [body] 1`] = `
Object {
"members": Array [
@ -240,6 +432,54 @@ Object {
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with post attribution 3: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Any<Object>,
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"email_suppression": Object {
"info": null,
"suppressed": false,
},
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": false,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with post attribution 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2398",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with tag attribution 1: [body] 1`] = `
Object {
"members": Array [
@ -288,6 +528,54 @@ Object {
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with tag attribution 3: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Any<Object>,
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"email_suppression": Object {
"info": null,
"suppressed": false,
},
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": false,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with tag attribution 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2405",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with url attribution 1: [body] 1`] = `
Object {
"members": Array [
@ -336,6 +624,54 @@ Object {
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with url attribution 3: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Any<Object>,
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"email_suppression": Object {
"info": null,
"suppressed": false,
},
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": false,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with url attribution 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2362",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent without attribution 1: [body] 1`] = `
Object {
"members": Array [
@ -384,6 +720,54 @@ Object {
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent without attribution 3: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Any<Object>,
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": Any<String>,
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"email_suppression": Object {
"info": null,
"suppressed": false,
},
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": null,
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": false,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Member attribution Creates a SubscriptionCreatedEvent without attribution 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2335",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Returns subscription created attributions in activity feed 1: [body] 1`] = `
Object {
"events": Array [
@ -419,25 +803,70 @@ Object {
"data": Any<Object>,
"type": "subscription_event",
},
Object {
"data": Any<Object>,
"type": "subscription_event",
},
Object {
"data": Any<Object>,
"type": "subscription_event",
},
Object {
"data": Any<Object>,
"type": "subscription_event",
},
Object {
"data": Any<Object>,
"type": "subscription_event",
},
Object {
"data": Any<Object>,
"type": "subscription_event",
},
Object {
"data": Any<Object>,
"type": "subscription_event",
},
Object {
"data": Any<Object>,
"type": "subscription_event",
},
Object {
"data": Any<Object>,
"type": "subscription_event",
},
],
"meta": Object {
"pagination": Object {
"limit": 10,
"limit": "100",
"next": null,
"page": null,
"pages": 1,
"prev": null,
"total": 8,
"total": 16,
},
},
}
`;
exports[`Members API Member attribution Returns subscription created attributions in activity feed 1: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "7027",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Member attribution Returns subscription created attributions in activity feed 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "5664",
"content-length": "11237",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View file

@ -1855,7 +1855,7 @@ describe('Members API', function () {
});
});
async function testWithAttribution(attribution, attributionResource) {
async function testAttributionOnSignup(attribution, attributionResource) {
const customer_id = createStripeID('cust');
const subscription_id = createStripeID('sub');
@ -1900,7 +1900,31 @@ describe('Members API', function () {
}
});
let webhookPayload = JSON.stringify({
// Stripe first sends a customer.subscription.created webhook
const subscriptionWebhookPayload = JSON.stringify({
type: 'customer.subscription.created',
data: {
object: subscription
}
});
const subscriptionWebhookSignature = stripe.webhooks.generateTestHeaderString({
payload: subscriptionWebhookPayload,
secret: process.env.WEBHOOK_SECRET
});
await membersAgent.post('/webhooks/stripe/')
.body(subscriptionWebhookPayload)
.header('content-type', 'application/json')
.header('stripe-signature', subscriptionWebhookSignature)
.expectStatus(200);
// This should not create a member in the database yet
const {body: bodyAfterSubscriptionWebhook} = await adminAgent.get(`/members/?search=${customer_id}@email.com`);
assert.equal(bodyAfterSubscriptionWebhook.members.length, 0, 'A member was created before the checkout.session.completed webhook was sent');
// Then it sends a checkout.session.completed webhook
const checkoutWebhookPayload = JSON.stringify({
type: 'checkout.session.completed',
data: {
object: {
@ -1916,15 +1940,15 @@ describe('Members API', function () {
}
});
let webhookSignature = stripe.webhooks.generateTestHeaderString({
payload: webhookPayload,
const checkoutWebhookSignature = stripe.webhooks.generateTestHeaderString({
payload: checkoutWebhookPayload,
secret: process.env.WEBHOOK_SECRET
});
await membersAgent.post('/webhooks/stripe/')
.body(webhookPayload)
.body(checkoutWebhookPayload)
.header('content-type', 'application/json')
.header('stripe-signature', webhookSignature)
.header('stripe-signature', checkoutWebhookSignature)
.expectStatus(200);
const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`);
@ -1993,6 +2017,161 @@ describe('Members API', function () {
return memberModel;
}
async function testAttributionOnUpgrade(attribution, attributionResource) {
const customer_id = createStripeID('cust');
const subscription_id = createStripeID('sub');
const interval = 'month';
const unit_amount = 150;
// Create initial free member
const initialFreeMember = await models.Member.add({
email: `${customer_id}@email.com`,
status: 'free',
email_disabled: false
});
// Create a Stripe Customer too for the free member, as this is created during Stripe Checkout, i.e. before receiving Stripe webhooks
await models.MemberStripeCustomer.add({
member_id: initialFreeMember.id,
customer_id: customer_id,
email: initialFreeMember.get('email')
});
set(subscription, {
id: subscription_id,
customer: customer_id,
status: 'active',
items: {
type: 'list',
data: [{
id: 'item_123',
price: {
id: 'price_123',
product: 'product_123',
active: true,
nickname: interval,
currency: 'usd',
recurring: {
interval
},
unit_amount,
type: 'recurring'
}
}]
},
start_date: beforeNow / 1000,
current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31),
cancel_at_period_end: false,
metadata: {}
});
set(customer, {
id: customer_id,
name: 'Test Member',
email: `${customer_id}@email.com`,
subscriptions: {
type: 'list',
data: [subscription]
}
});
// Stripe first sends a customer.subscription.created webhook
const subscriptionWebhookPayload = JSON.stringify({
type: 'customer.subscription.created',
data: {
object: subscription
}
});
const subscriptionWebhookSignature = stripe.webhooks.generateTestHeaderString({
payload: subscriptionWebhookPayload,
secret: process.env.WEBHOOK_SECRET
});
await membersAgent.post('/webhooks/stripe/')
.body(subscriptionWebhookPayload)
.header('content-type', 'application/json')
.header('stripe-signature', subscriptionWebhookSignature)
.expectStatus(200);
// This should not create a member subscription in the database yet
const {body: bodyAfterSubscriptionWebhook} = await adminAgent.get(`/members/?search=${customer_id}@email.com`);
const memberAfterSubscriptionWebhook = bodyAfterSubscriptionWebhook.members[0];
assert.equal(memberAfterSubscriptionWebhook.subscriptions.length, 0, 'The member should not have a subscription after customer.subscription.updated');
assert.equal(memberAfterSubscriptionWebhook.status, 'free', 'The member status should still be "free" after customer.subscription.updated');
// Then it sends a checkout.session.completed webhook
const checkoutWebhookPayload = JSON.stringify({
type: 'checkout.session.completed',
data: {
object: {
mode: 'subscription',
customer: customer.id,
subscription: subscription.id,
metadata: attribution ? {
attribution_id: attribution.id,
attribution_url: attribution.url,
attribution_type: attribution.type
} : {}
}
}
});
const checkoutWebhookSignature = stripe.webhooks.generateTestHeaderString({
payload: checkoutWebhookPayload,
secret: process.env.WEBHOOK_SECRET
});
await membersAgent.post('/webhooks/stripe/')
.body(checkoutWebhookPayload)
.header('content-type', 'application/json')
.header('stripe-signature', checkoutWebhookSignature)
.expectStatus(200);
const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`);
assert.equal(body.members.length, 1, 'The member was not created');
const member = body.members[0];
assert.equal(member.status, 'paid', 'The member should be "paid" after checkout.session.completed');
assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription after checkout.session.completed');
// Convert Stripe ID to internal model ID
const subscriptionModel = await getSubscription(member.subscriptions[0].id);
await assertMemberEvents({
eventType: 'SubscriptionCreatedEvent',
memberId: member.id,
asserts: [
{
member_id: member.id,
subscription_id: subscriptionModel.id,
// Defaults if attribution is not set
attribution_id: attribution?.id ?? null,
attribution_url: attribution?.url ?? null,
attribution_type: attribution?.type ?? null
}
]
});
await adminAgent
.get(`/members/${member.id}/`)
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(memberMatcherShallowIncludes)
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.expect(({body: body3}) => {
should(body3.members[0].subscriptions[0].attribution).eql(attributionResource);
subscriptionAttributions.push(body3.members[0].subscriptions[0].attribution);
});
}
const subscriptionAttributions = [];
it('Creates a SubscriptionCreatedEvent with url attribution', async function () {
@ -2005,7 +2184,7 @@ describe('Members API', function () {
const absoluteUrl = urlUtils.createUrl('/', true);
await testWithAttribution(attribution, {
const attributionResource = {
id: null,
url: absoluteUrl,
type: 'url',
@ -2013,7 +2192,10 @@ describe('Members API', function () {
referrer_source: null,
referrer_medium: null,
referrer_url: null
});
};
await testAttributionOnSignup(attribution, attributionResource);
await testAttributionOnUpgrade(attribution, attributionResource);
});
it('Creates a SubscriptionCreatedEvent with post attribution', async function () {
@ -2028,7 +2210,7 @@ describe('Members API', function () {
const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true});
await testWithAttribution(attribution, {
const attributionResource = {
id: post.id,
url: absoluteUrl,
type: 'post',
@ -2036,7 +2218,10 @@ describe('Members API', function () {
referrer_source: null,
referrer_medium: null,
referrer_url: null
});
};
await testAttributionOnSignup(attribution, attributionResource);
await testAttributionOnUpgrade(attribution, attributionResource);
});
it('Creates a SubscriptionCreatedEvent with deleted post attribution', async function () {
@ -2048,7 +2233,7 @@ describe('Members API', function () {
const absoluteUrl = urlUtils.createUrl('/removed-blog-post/', true);
await testWithAttribution(attribution, {
const attributionResource = {
id: null,
url: absoluteUrl,
type: 'url',
@ -2056,7 +2241,10 @@ describe('Members API', function () {
referrer_source: null,
referrer_medium: null,
referrer_url: null
});
};
await testAttributionOnSignup(attribution, attributionResource);
await testAttributionOnUpgrade(attribution, attributionResource);
});
it('Creates a SubscriptionCreatedEvent with page attribution', async function () {
@ -2071,7 +2259,7 @@ describe('Members API', function () {
const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true});
await testWithAttribution(attribution, {
const attributionResource = {
id: post.id,
url: absoluteUrl,
type: 'page',
@ -2079,7 +2267,10 @@ describe('Members API', function () {
referrer_source: null,
referrer_medium: null,
referrer_url: null
});
};
await testAttributionOnSignup(attribution, attributionResource);
await testAttributionOnUpgrade(attribution, attributionResource);
});
it('Creates a SubscriptionCreatedEvent with tag attribution', async function () {
@ -2094,7 +2285,7 @@ describe('Members API', function () {
const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true});
await testWithAttribution(attribution, {
const attributionResource = {
id: tag.id,
url: absoluteUrl,
type: 'tag',
@ -2102,7 +2293,10 @@ describe('Members API', function () {
referrer_source: null,
referrer_medium: null,
referrer_url: null
});
};
await testAttributionOnSignup(attribution, attributionResource);
await testAttributionOnUpgrade(attribution, attributionResource);
});
it('Creates a SubscriptionCreatedEvent with author attribution', async function () {
@ -2117,7 +2311,7 @@ describe('Members API', function () {
const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true});
await testWithAttribution(attribution, {
const attributionResource = {
id: author.id,
url: absoluteUrl,
type: 'author',
@ -2125,12 +2319,16 @@ describe('Members API', function () {
referrer_source: null,
referrer_medium: null,
referrer_url: null
});
};
await testAttributionOnSignup(attribution, attributionResource);
await testAttributionOnUpgrade(attribution, attributionResource);
});
it('Creates a SubscriptionCreatedEvent without attribution', async function () {
const attribution = undefined;
await testWithAttribution(attribution, {
const attributionResource = {
id: null,
url: null,
type: null,
@ -2138,13 +2336,17 @@ describe('Members API', function () {
referrer_source: null,
referrer_medium: null,
referrer_url: null
});
};
await testAttributionOnSignup(attribution, attributionResource);
await testAttributionOnUpgrade(attribution, attributionResource);
});
it('Creates a SubscriptionCreatedEvent with empty attribution object', async function () {
// Shouldn't happen, but to make sure we handle it
const attribution = {};
await testWithAttribution(attribution, {
const attributionResource = {
id: null,
url: null,
type: null,
@ -2152,7 +2354,10 @@ describe('Members API', function () {
referrer_source: null,
referrer_medium: null,
referrer_url: null
});
};
await testAttributionOnSignup(attribution, attributionResource);
await testAttributionOnUpgrade(attribution, attributionResource);
});
// Activity feed
@ -2160,7 +2365,7 @@ describe('Members API', function () {
it('Returns subscription created attributions in activity feed', async function () {
// Check activity feed
await adminAgent
.get(`/members/events/?filter=type:subscription_event`)
.get(`/members/events/?filter=type:subscription_event&limit=100`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
@ -2168,7 +2373,8 @@ describe('Members API', function () {
})
.matchBodySnapshot({
events: new Array(subscriptionAttributions.length).fill({
data: anyObject
data: anyObject,
type: 'subscription_event'
})
})
.expect(({body}) => {

View file

@ -1,5 +1,6 @@
const errors = require('@tryghost/errors');
const _ = require('lodash');
const logging = require('@tryghost/logging');
module.exports = class SubscriptionEventService {
constructor(deps) {
this.deps = deps;
@ -18,18 +19,35 @@ module.exports = class SubscriptionEventService {
customer_id: subscription.customer
});
if (member) {
try {
await memberRepository.linkSubscription({
id: member.id,
subscription
});
} catch (err) {
if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
throw err;
}
throw new errors.ConflictError({err});
// After checkout, Stripe sends `customer.subscription.created`, `customer.subscription.updated` and `checkout.session.completed` events
// We want to create a member and its related subscription in the database based on the `checkout.session.completed` event as it contains additional information on the subscription (e.g. attribution data)
// Therefore, if the member or the subscription does not exist in the database yet, we ignore `customer.subscription.*` events, to avoid creating subscriptions with missing data
if (!member) {
logging.info(`Ignoring customer.subscription.* event as member does not exist`);
return;
}
const memberSubscription = await member.related('stripeSubscriptions').query({
where: {
subscription_id: subscription.id
}
}).fetchOne();
if (!memberSubscription) {
logging.info(`Ignoring customer.subscription.* event as member subscription does not exist`);
return;
}
try {
await memberRepository.linkSubscription({
id: member.id,
subscription
});
} catch (err) {
if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
throw err;
}
throw new errors.ConflictError({err});
}
}
};

View file

@ -6,15 +6,55 @@ const SubscriptionEventService = require('../../../../../lib/services/webhook/Su
describe('SubscriptionEventService', function () {
let service;
let memberRepository;
let member;
let subscription;
beforeEach(function () {
memberRepository = {get: sinon.stub(), linkSubscription: sinon.stub()};
member = {
id: 'member_123',
related: sinon.stub().returns({
query: sinon.stub().returns({
fetchOne: sinon.stub().resolves({subscription_id: 'sub_123'})
})
})
};
memberRepository = {
get: sinon.stub().resolves(member),
linkSubscription: sinon.stub()
};
subscription = {
id: 'sub_123',
items: {
data: [{price: {id: 'price_123'}}]
},
customer: 'cust_123'
};
service = new SubscriptionEventService({memberRepository});
});
it('should not call linkSubscription if member does not exist', async function () {
memberRepository.get.resolves(null);
await service.handleSubscriptionEvent(subscription);
assert(memberRepository.linkSubscription.notCalled);
});
it('should not call linkSubscription if member subscription does not exist', async function () {
member.related.returns({
query: sinon.stub().returns({
fetchOne: sinon.stub().resolves(null)
})
});
await service.handleSubscriptionEvent(subscription);
assert(memberRepository.linkSubscription.notCalled);
});
it('should throw BadRequestError if subscription has no price item', async function () {
const subscription = {
subscription = {
items: {
data: []
}
@ -29,14 +69,6 @@ describe('SubscriptionEventService', function () {
});
it('should throw ConflictError if linkSubscription fails with ER_DUP_ENTRY', async function () {
const subscription = {
items: {
data: [{price: {id: 'price_123'}}]
},
customer: 'cust_123'
};
memberRepository.get.resolves({id: 'member_123'});
memberRepository.linkSubscription.rejects({code: 'ER_DUP_ENTRY'});
try {
@ -48,14 +80,6 @@ describe('SubscriptionEventService', function () {
});
it('should throw ConflictError if linkSubscription fails with SQLITE_CONSTRAINT', async function () {
const subscription = {
items: {
data: [{price: {id: 'price_123'}}]
},
customer: 'cust_123'
};
memberRepository.get.resolves({id: 'member_123'});
memberRepository.linkSubscription.rejects({code: 'SQLITE_CONSTRAINT'});
try {
@ -67,14 +91,6 @@ describe('SubscriptionEventService', function () {
});
it('should throw if linkSubscription fails with unexpected error', async function () {
const subscription = {
items: {
data: [{price: {id: 'price_123'}}]
},
customer: 'cust_123'
};
memberRepository.get.resolves({id: 'member_123'});
memberRepository.linkSubscription.rejects(new Error('Unexpected error'));
try {
@ -97,15 +113,6 @@ describe('SubscriptionEventService', function () {
});
it('should call linkSubscription with correct arguments', async function () {
const subscription = {
items: {
data: [{price: {id: 'price_123'}}]
},
customer: 'cust_123'
};
memberRepository.get.resolves({id: 'member_123'});
await service.handleSubscriptionEvent(subscription);
assert(memberRepository.linkSubscription.calledWith({id: 'member_123', subscription}));