Skip to content

Commit

Permalink
Merge pull request #55 from gadget-inc/usage-updates
Browse files Browse the repository at this point in the history
Updating usage app charges to use a new flow
  • Loading branch information
DevAOC authored Jun 27, 2024
2 parents dbaffd6 + 6b88458 commit f71cf3e
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 217 deletions.
109 changes: 33 additions & 76 deletions usage-subscription-template/api/actions/billingPeriodTracking.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { BillingPeriodTrackingGlobalActionContext } from "gadget-server";
import {
ActionOptions,
BillingPeriodTrackingGlobalActionContext,
} from "gadget-server";
import { DateTime } from "luxon";
import { getCappedAmount } from "../../utilities";

/**
* @param { BillingPeriodTrackingGlobalActionContext } context
Expand All @@ -20,16 +22,21 @@ export async function run({ params, logger, api, connections }) {
state: {
inState: "installed",
},
plan: {
isSet: true,
},
},
select: {
id: true,
name: true,
billingPeriodEnd: true,
usagePlanId: true,
currency: true,
overage: true,
activeSubscriptionId: true,
plan: {
currency: true,
pricePerOrder: true,
},
},
first: 250,
Expand All @@ -43,95 +50,45 @@ export async function run({ params, logger, api, connections }) {
}

for (const shop of allShops) {
let remainder = 0;

if (shop.overage) {
const shopify = await connections.shopify.forShopId(shop.id);

const activeSubscription = await api.shopifyAppSubscription.maybeFindOne(
shop.activeSubscriptionId,
{
select: {
lineItems: true,
await api.enqueue(
api.chargeShop,
{
shop: {
id: shop.id,
currency: shop.currency,
overage: shop.overage,
activeSubscriptionId: shop.activeSubscriptionId,
usagePlanId: shop.usagePlanId,
plan: {
currency: shop.plan.currency,
price: shop.plan.pricePerOrder,
},
}
);

if (!activeSubscription) {
logger.warn({
message:
"NO ACTIVE SUBSCRIPTION - Cannot charge overages because the shop has no active subscription",
shopId: shop.id,
in: "billingPeriodTracking.js",
});
continue;
}

const cappedAmount = getCappedAmount(activeSubscription);

if (!cappedAmount) {
logger.warn({
message:
"NO CAPPED AMOUNT - Active subscription missing a capped amount",
shopId: shop.id,
in: "billingPeriodTracking.js",
});
continue;
}

let price = shop.overage;

if (price > cappedAmount) {
remainder = price - cappedAmount;
price = cappedAmount;
}

const result = await shopify.graphql(`
mutation {
appUsageRecordCreate(
description: "Charge of ${price} ${shop.currency} for overages from the previous billing period",
price: {
amount: ${price},
currencyCode: ${shop.currency},
},
subscriptionLineItemId: "${shop.usagePlanId}") {
appUsageRecord {
id
}
userErrors {
field
message
}
}
}
`);

if (result?.appUsageRecordCreate.userErrors.length) {
logger.error({
message:
result?.appUsageRecordCreate?.userErrors[0]?.message ||
`FAILED USAGE CHARGE CREATION - Error creating app usage record (SHOPIFY API)`,
shopId: shop.id,
in: "billingPeriodTracking.js",
});
},
},
{
queue: {
name: shop.name,
maxConcurrency: 4,
},
retries: 1,
}
}
);

// Updating billing period information
await api.internal.shopifyShop.update(shop.id, {
billingPeriodStart: DateTime.fromJSDate(new Date(shop.billingPeriodEnd))
.plus({ milliseconds: 1 })
.toJSDate(),
billingPeriodEnd: DateTime.fromJSDate(new Date(shop.billingPeriodEnd))
.plus({ days: 30 })
.toJSDate(),
overage: remainder,
});
}
}

// Action timeout set to 5 minutes (300,000 milliseconds)
/** @type { ActionOptions } */
export const options = {
timeoutMS: 300000,
timeoutMS: 900000,
triggers: {
api: true,
scheduler: [{ cron: "*/5 * * * *" }],
Expand Down
160 changes: 160 additions & 0 deletions usage-subscription-template/api/actions/chargeShop.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { ActionOptions, ChargeShopGlobalActionContext } from "gadget-server";
import { getCappedAmount } from "../../utilities";

/**
* @param { ChargeShopGlobalActionContext } context
*/
export async function run({ params, logger, api, connections }) {
const { shop, order } = params;

// Creating an instance of the Shopify Admin API
const shopify = await connections.shopify.forShopId(shop.id);

// Returning early if the shop is uninstalled and no Shopify instance is created
if (!shopify)
return logger.warn({
message: "BILLING - Shop uninstalled",
shopId: shop.id,
});

// Fetching the amount used in the period
const { amountUsedInPeriod } = await api.shopifyShop.findOne(shop.id, {
amountUsedInPeriod: true,
});

let remainder = 0;

// Fetching the subscription at the time
const subscription = await api.shopifyAppSubscription.maybeFindOne(
shop?.activeSubscriptionId,
{
select: {
lineItems: true,
},
}
);

if (!subscription)
return logger.warn({
message: "BILLING - No subscription found for the shop",
shopId: shop.id,
});

// Pulling out the capped amount from the active subscription
const cappedAmount = getCappedAmount(subscription);

if (!cappedAmount)
return logger.warn({
message: "BILLING - No capped amount found for the shop",
shopId: shop.id,
});

// Initially setting the price to the plan price and adding the overage amount if they exist
let price = shop.plan?.price + (shop.overage || 0) || 0;

// Returning early if the amount used in the period is greater than or equal to the capped amount
if (amountUsedInPeriod >= cappedAmount) {
// Modifying the overage amount of the current shop
return await api.internal.shopifyShop.update(shop.id, {
overage: price,
});
}

// Calculating the available amount
const availableAmount = cappedAmount - amountUsedInPeriod;

// Setting a remainder if the price is greater than the available amount
if (price >= availableAmount) {
remainder = price - availableAmount;
price = availableAmount;
}

// Creating the usage charge with the Shopify Billing API
const result = await shopify.graphql(
`mutation ($description: String!, $price: MoneyInput!, $subscriptionLineItemId: ID!) {
appUsageRecordCreate(description: $description, price: $price, subscriptionLineItemId: $subscriptionLineItemId) {
appUsageRecord {
id
}
userErrors {
field
message
}
}
}`,
{
description: shop.overage
? `Charge of ${price} ${shop.currency} ${
order.email ? `for order placed by ${order.email}` : ""
}, with overages from the previous billing period`
: `Charge of ${price} ${shop.currency} for order placed by ${order.email}`,
price: {
amount: price,
currencyCode: shop.currency,
},
subscriptionLineItemId: shop.usagePlanId,
}
);

// Throwing an error if the charge fails
if (result?.appUsageRecordCreate?.userErrors?.length)
throw new Error(result.appUsageRecordCreate.userErrors[0].message);

// Creating the usage charge record in the database
await api.internal.usageRecord.create({
id: result.appUsageRecordCreate.appUsageRecord.id.split("/")[4],
price,
currency: shop.currency,
shop: {
_link: shop.id,
},
});

// Updating the overage amount if there is a remainder
if (remainder)
await api.internal.shopifyShop.update(shop.id, {
overage: remainder,
});
}

/** @type { ActionOptions } */
export const options = {};

export const params = {
shop: {
type: "object",
properties: {
id: {
type: "string",
},
activeSubscriptionId: {
type: "string",
},
usagePlanId: {
type: "string",
},
currency: {
type: "string",
},
overage: {
type: "number",
},
plan: {
type: "object",
properties: {
price: {
type: "number",
},
},
},
},
},
order: {
type: "object",
properties: {
email: {
type: "string",
},
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { convertCurrency } from "../../utilities";
export async function run({ params, logger, api, connections }) {
const response = [];

// Getting the current shop's currency
const shop = await api.shopifyShop.maybeFindOne(
connections.shopify.currentShopId,
{
Expand Down
Loading

0 comments on commit f71cf3e

Please sign in to comment.