Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ https://skyhookapi.com/api/webhooks/firstPartOfWebhook/secondPartOfWebhook/provi
`/newrelic`
- [Patreon](https://www.patreon.com/platform/documentation/webhooks) - `/patreon`
- [Pingdom](https://www.pingdom.com/resources/webhooks) - `/pingdom`
- [RevenueCat](https://www.revenuecat.com/docs/integrations/webhooks) - `/revenuecat`
- [Rollbar](https://docs.rollbar.com/docs/webhooks) - `/rollbar`
- [Shopify](https://shopify.dev/docs/api/webhooks/latest) - `/shopify`
- [Square](https://developer.squareup.com/docs/webhooks/overview) - `/square`
Expand Down
12 changes: 12 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@

Buildkite is a trademark of Buildkite Pty Ltd. The icon is used only to identify the supported provider.

## Simple Icons — RevenueCat icon

- File: `web/public/providers/revenuecat.svg`
- Project: Simple Icons
- Source: https://github.com/simple-icons/simple-icons/blob/25d6e5b39bc55bc446e147700294628af1734f7e/icons/revenuecat.svg
- Revision: `25d6e5b39bc55bc446e147700294628af1734f7e`
- Imported size: 1497 bytes
- Imported SHA-256: `1e224d9067e9b9b2e7366d4f38c1670d5c6131baf9b408a1a6039a8c8f285156`
- License: CC0 1.0 Universal; a copy is included at `web/public/providers/LICENSE.simple-icons.md`

RevenueCat is a trademark of RevenueCat, Inc. The icon is used only to identify the supported provider.

## Simple Icons — Stripe icon

- File: `web/public/providers/stripe.svg`
Expand Down
49 changes: 49 additions & 0 deletions examples/revenuecat/revenuecat.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"event": {
"event_timestamp_ms": 1658726378679,
"product_id": "com.subscription.weekly",
"period_type": "NORMAL",
"purchased_at_ms": 1658726374000,
"expiration_at_ms": 1659331174000,
"environment": "PRODUCTION",
"entitlement_id": null,
"entitlement_ids": [
"pro"
],
"presented_offering_id": null,
"transaction_id": "123456789012345",
"original_transaction_id": "123456789012345",
"is_family_share": false,
"country_code": "US",
"app_user_id": "1234567890",
"aliases": [
"$RCAnonymousID:8069238d6049ce87cc529853916d624c"
],
"original_app_user_id": "$RCAnonymousID:87c6049c58069238dce29853916d624c",
"currency": "USD",
"price": 4.99,
"price_in_purchased_currency": 4.99,
"subscriber_attributes": {
"$email": {
"updated_at_ms": 1662955084635,
"value": "firstlast@gmail.com"
}
},
"store": "APP_STORE",
"takehome_percentage": 0.7,
"tax_percentage": 0,
"commission_percentage": 0.3,
"offer_code": null,
"type": "INITIAL_PURCHASE",
"id": "12345678-1234-1234-1234-123456789012",
"app_id": "1234567890",
"experiments": [
{
"experiment_id": "prexp123",
"experiment_variant": "b",
"enrolled_at_ms": 1658726378679
}
]
},
"api_version": "1.0"
}
2 changes: 2 additions & 0 deletions src/provider/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { NewRelic } from './NewRelic.ts'
import { Patreon } from './Patreon.ts'
import { Pingdom } from './Pingdom.ts'
import type { ProviderDefinition } from './Provider.ts'
import { RevenueCat } from './RevenueCat.ts'
import { Rollbar } from './Rollbar.ts'
import { Shopify } from './Shopify.ts'
import { Square } from './Square.ts'
Expand Down Expand Up @@ -89,6 +90,7 @@ const providerDefinitions: readonly ProviderDefinition[] = [
NewRelic,
Patreon,
Pingdom,
RevenueCat,
Rollbar,
Shopify,
Square,
Expand Down
265 changes: 265 additions & 0 deletions src/provider/RevenueCat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import type { Embed, EmbedField } from '../model/DiscordApi.ts'
import { DISCORD_EMBED_LIMITS, fitLiteralEmbedFields } from '../util/DiscordEmbed.ts'
import { cleanText, escapeDiscordMarkdownLiteral, humanizeWords, truncateText } from '../util/DiscordText.ts'
import { isRecord, scalarText } from '../util/WebhookValue.ts'
import { defineProvider } from './Provider.ts'

const MAX_API_VERSION_CHARACTERS = 32
const MAX_EVENT_TYPE_CHARACTERS = 200
const MAX_EVENT_ID_CHARACTERS = 512
const MAX_FIELD_SOURCE_CHARACTERS = 2_048
const MAX_LIST_ITEMS = 10
const MAX_ADJUSTMENTS = 8
const MAX_UNIX_TIMESTAMP_MILLISECONDS = 253_402_300_799_999

const ENUM_LABELS: Readonly<Record<string, string>> = {
AMAZON: 'Amazon',
ANDROID: 'Android',
APP_STORE: 'App Store',
ADMIN_API: 'Admin API',
CLIENT_SDK: 'Client SDK',
CUSTOMER_SUPPORT: 'Customer Support',
IN_APP_PURCHASE: 'In App Purchase',
IOS: 'iOS',
MACOS: 'macOS',
MAC_APP_STORE: 'Mac App Store',
PLAY_STORE: 'Google Play',
RC_BILLING: 'RevenueCat Billing',
ROKU: 'Roku',
SERVER_API: 'Server API',
TEST_STORE: 'Test Store',
WEB: 'Web',
}

/**
* Converts RevenueCat webhook events into bounded Discord embeds.
*
* @see https://www.revenuecat.com/docs/integrations/webhooks
* @see https://www.revenuecat.com/docs/integrations/webhooks/event-types-and-fields
*/
export const RevenueCat = defineProvider({
path: 'revenuecat',
name: 'RevenueCat',
example: { body: 'revenuecat/revenuecat.json' },
defaults: {
username: 'RevenueCat',
embedColor: 0xf2545b,
},
map({ body }, output) {
const apiVersion = requiredText(body.api_version, MAX_API_VERSION_CHARACTERS)
const event = isRecord(body.event) ? body.event : null
const eventId = requiredText(event?.id, MAX_EVENT_ID_CHARACTERS)
const eventType = requiredText(event?.type, MAX_EVENT_TYPE_CHARACTERS)
const timestamp = revenueCatTimestamp(event?.event_timestamp_ms)
const title =
eventType == null ? '' : boundedLiteral(humanizeWords(eventType), DISCORD_EMBED_LIMITS.title, true)
if (
apiVersion == null ||
event == null ||
eventId == null ||
eventType == null ||
timestamp == null ||
title.length === 0
) {
output.ignore()
return
}

const embed: Embed = {
title,
timestamp,
}
embed.fields = fitLiteralEmbedFields(embed, createFields(event, eventType, eventId))
output.addEmbed(embed)
},
})

function createFields(event: Record<string, any>, eventType: string, eventId: string): EmbedField[] {
const fields: EmbedField[] = []

if (isPaywallEvent(eventType, event)) {
addTextField(fields, 'Paywall', event.paywall_name)
addTextField(fields, 'Paywall ID', event.paywall_id)
addTextField(fields, 'Offering', event.offering_id)
addEnumField(fields, 'Component', event.component_type)
addTextField(fields, 'Component value', event.component_value, false)
addEnumField(fields, 'Platform', event.platform)
addTextField(fields, 'Paywall event ID', event.event_id)
addTextField(fields, 'Session ID', event.session_id)
} else if (eventType === 'TRANSFER') {
addListField(fields, 'Transferred from', event.transferred_from)
addListField(fields, 'Transferred to', event.transferred_to)
} else if (eventType === 'VIRTUAL_CURRENCY_TRANSACTION') {
addTextField(fields, 'Adjustment', adjustmentSummary(event.adjustments), false)
addEnumField(fields, 'Source', event.source)
addTextField(fields, 'Virtual currency transaction ID', event.virtual_currency_transaction_id)
addTextField(fields, 'Product', event.product_id)
} else if (eventType === 'EXPERIMENT_ENROLLMENT') {
addTextField(fields, 'Experiment ID', event.experiment_id)
addTextField(fields, 'Variant', event.experiment_variant)
addTextField(fields, 'Offering', event.offering_id)
addTimestampField(fields, 'Enrolled at', event.experiment_enrolled_at_ms)
} else if (eventType === 'PURCHASE_REDEEMED') {
addEnumField(fields, 'Outcome', event.redemption_outcome)
addListField(fields, 'Redeemed from', event.redeemed_from)
addListField(fields, 'Redeemed by', event.redeemed_by)
addEnumField(fields, 'Platform', event.redemption_platform)
addTextField(fields, 'Product', event.product_id)
addListField(fields, 'Entitlements', event.entitlement_ids)
} else {
addPurchaseFields(fields, event)
}

addTextField(fields, 'App user ID', event.app_user_id)
addEnumField(fields, 'Store', event.store)
addEnumField(fields, 'Environment', event.environment ?? event.purchase_environment)
addTextField(fields, 'Event ID', eventId)
return fields
}

function addPurchaseFields(fields: EmbedField[], event: Record<string, any>): void {
addTextField(fields, 'Product', event.product_id)
addTextField(fields, 'Price', revenueCatPrice(event))
addTextField(fields, 'New product', event.new_product_id)
addEnumField(fields, 'Period', event.period_type)
addListField(fields, 'Entitlements', event.entitlement_ids)
addEnumField(fields, 'Reason', event.cancel_reason ?? event.expiration_reason)
addTimestampField(fields, 'Expires', event.expiration_at_ms)
addTimestampField(fields, 'Grace period ends', event.grace_period_expiration_at_ms)
addTimestampField(fields, 'Auto resumes', event.auto_resume_at_ms)
addTextField(fields, 'Transaction ID', event.transaction_id)
}

function addTextField(fields: EmbedField[], name: string, value: unknown, inline = true): void {
const text = boundedFieldText(value)
if (text != null) {
fields.push({ name, value: text, inline })
}
}

function addEnumField(fields: EmbedField[], name: string, value: unknown, inline = true): void {
const text = boundedFieldText(value)
if (text != null) {
fields.push({ name, value: enumLabel(text), inline })
}
}

function addTimestampField(fields: EmbedField[], name: string, value: unknown): void {
const timestamp = revenueCatTimestamp(value)
if (timestamp != null) {
fields.push({ name, value: timestamp, inline: true })
}
}

function addListField(fields: EmbedField[], name: string, value: unknown): void {
const list = stringList(value)
if (list != null) {
fields.push({ name, value: list, inline: false })
}
}

function stringList(value: unknown): string | null {
if (!Array.isArray(value)) {
return null
}
const items = value.map(boundedFieldText).filter((item): item is string => item != null)
if (items.length === 0) {
return null
}
const visible = items.slice(0, MAX_LIST_ITEMS)
const suffix = items.length > visible.length ? ` (+${items.length - visible.length} more)` : ''
return `${visible.join(', ')}${suffix}`
}

function adjustmentSummary(value: unknown): string | null {
if (!Array.isArray(value)) {
return null
}
const adjustments: string[] = []
for (const candidate of value.slice(0, MAX_ADJUSTMENTS)) {
if (!isRecord(candidate) || !Number.isSafeInteger(candidate.amount) || !isRecord(candidate.currency)) {
continue
}
const code = requiredText(candidate.currency.code, 64)
const name = requiredText(candidate.currency.name, 128) ?? code
if (code == null || name == null) {
continue
}
adjustments.push(`${candidate.amount} ${name}${name === code ? '' : ` (${code})`}`)
}
if (adjustments.length === 0) {
return null
}
const omitted = value.length - adjustments.length
return `${adjustments.join(', ')}${omitted > 0 ? ` (+${omitted} more)` : ''}`
}

function revenueCatPrice(event: Record<string, any>): string | null {
const currency = currencyCode(event.currency)
const purchasedPrice = finitePrice(event.price_in_purchased_currency)
if (currency != null && purchasedPrice != null) {
return formatPrice(purchasedPrice, currency)
}

const usdPrice = finitePrice(event.price)
return usdPrice == null ? null : formatPrice(usdPrice, 'USD')
}

function formatPrice(value: number, currency: string): string | null {
try {
const fractionDigits =
new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).resolvedOptions().maximumFractionDigits ?? 2
return `${value.toFixed(fractionDigits)} ${currency}`
} catch {
return null
}
}

function finitePrice(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= Number.MAX_SAFE_INTEGER
? value
: null
}

function currencyCode(value: unknown): string | null {
return typeof value === 'string' && /^[A-Za-z]{3}$/.test(value) ? value.toUpperCase() : null
}

function isPaywallEvent(eventType: string, event: Record<string, any>): boolean {
return eventType.startsWith('PAYWALL_') || event.paywall_id != null || event.paywall_name != null
}

function requiredText(value: unknown, maxLength: number): string | null {
if (typeof value !== 'string') {
return null
}
const text = cleanText(value, true)
return text.length > 0 && text.length <= maxLength ? text : null
}

function boundedFieldText(value: unknown): string | null {
const text = scalarText(value)
return text == null ? null : truncateText(text, MAX_FIELD_SOURCE_CHARACTERS, false)
}

function revenueCatTimestamp(value: unknown): string | null {
if (!Number.isSafeInteger(value) || Number(value) < 0 || Number(value) > MAX_UNIX_TIMESTAMP_MILLISECONDS) {
return null
}
try {
return new Date(Number(value)).toISOString()
} catch {
return null
}
}

function enumLabel(value: string): string {
return ENUM_LABELS[value.toUpperCase()] ?? humanizeWords(value)
}

function boundedLiteral(value: string, maxLength: number, singleLine: boolean): string {
return truncateText(escapeDiscordMarkdownLiteral(value), maxLength, singleLine)
}
1 change: 1 addition & 0 deletions test/examples/examples-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const expectedProviderPaths = [
'newrelic',
'patreon',
'pingdom',
'revenuecat',
'rollbar',
'shopify',
'square',
Expand Down
1 change: 1 addition & 0 deletions test/provider/provider-registry-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const expectedMetadata = [
example: { body: 'patreon/patreon-member-create.json', headers: 'patreon/patreon.headers.json' },
},
{ path: 'pingdom', name: 'Pingdom', example: { body: 'pingdom/pingdom.json' } },
{ path: 'revenuecat', name: 'RevenueCat', example: { body: 'revenuecat/revenuecat.json' } },
{ path: 'rollbar', name: 'Rollbar', example: { body: 'rollbar/rollbar.json' } },
{
path: 'shopify',
Expand Down
Loading
Loading