How to charge prepaid credits for TypeScript background jobs with Subscrio
RoomRaster turns floor plans into furnished room images. Customers prepay for rendering credits, and each image costs twelve. Near the end of a pack, two background workers may reach the same customer at once. Both can see a promising balance, but only one should spend the last twelve credits.
In this guide we will charge prepaid credits for background jobs in TypeScript, test competing debits, and make an explicit credit correction when an accepted render fails.
Atomic credit consumption lets the debit decide which background job can proceed when workers compete for the same balance. If admitted work fails, the application can record a separate credit adjustment once; that adjustment restores application credits, not a cash payment.
Define the customer promise
| Event | Available render credits |
|---|---|
| Prepaid pack | 300 |
| After 24 images at 12 credits each | 12 |
| First competing debit succeeds | 0 |
| Second debit | Rejected |
| Explicit correction for failed output | 12 |
Run the example
The complete RoomRaster sample contains the setup and executable assertions used below. Use Node.js and a local PostgreSQL server. Copy the environment example and set DATABASE_URL to a development connection with permission to create databases. The runner creates and removes its own isolated database.
git clone https://github.com/subscrio/samples.git
cd samples/examples/prepaid-background-jobs-typescript
cp .env.example .env
# Set DATABASE_URL in .env before running.
npm ci
npm test
The snippets run in order within that program. check prints the returned value and asserts that it matches the expected result. The outputs below come from the executable sample.
Initialize Subscrio
The helper supplies the disposable PostgreSQL database connection. Install the schema before creating any catalog records.
const clock: { at: Date | null } = { at: null };
const app = new Subscrio({
database: { connectionString: db.connectionString },
clock: { now: () => clock.at ?? new Date() }
});
await app.installSchema();
The full runner supplies the imports, database helper, and cleanup. The clock uses the current time throughout this example.
Create the product
Keep RoomRaster’s catalog under one product.
await app.products.createProduct({
key: 'roomraster',
displayName: 'RoomRaster'
});
Define the feature
The render toggle controls access to image rendering. A credit balance alone does not enable this feature.
await app.features.createFeature({
key: 'render',
displayName: 'render',
valueType: 'toggle',
defaultValue: 'false'
});
Associate the feature
Associate this feature with RoomRaster so its plan can supply the customer’s value.
await app.products.associateFeature('roomraster', 'render', {
subscriptionRule: 'most_generous'
});
Create the plan
This ongoing application plan permits rendering. We will configure the per-image credit cost and fund the wallet in separate steps.
await app.plans.createPlan({
key: 'standard',
productKey: 'roomraster',
displayName: 'standard'
});
Assign the plan value
The application plan enables rendering. Prepaid purchases supply the credits separately.
await app.plans.setFeatureValue('standard', 'render', 'true');
Create the billing cycle
The forever cycle represents a non-expiring agreement. It creates no recurring charge.
await app.billingCycles.createBillingCycle({
key: 'standard-cycle',
planKey: 'standard',
displayName: 'Ongoing',
durationUnit: 'forever'
});
Create the customer
The sample uses the key customer for the RoomRaster purchaser. Creating this record identifies the recipient; the subscription will assign their plan.
await app.customers.createCustomer({
key: 'customer',
displayName: 'RoomRaster customer'
});
Create the customer subscription
The agreement subscription selects the ongoing application plan through standard-cycle. It permits rendering, while the prepaid wallet determines whether the customer can afford each job.
await app.subscriptions.createSubscription({
key: 'agreement',
customerKey: 'customer',
billingCycleKey: 'standard-cycle',
activationDate: clock.at ?? new Date()
});
Configure render pricing
A render is one unit at twelve credits. The free ongoing agreement controls access; it does not fund the wallet.
await app.credits.createCurrency({
key: 'render-credits',
displayName: 'Render credits'
});
Set the cost per job
One room image consumes twelve render credits.
await app.credits.setConsumptionRule('render', 'render-credits', 12);
Load a confirmed prepaid purchase
The confirmed purchase fixture grants 300 credits. A charge for 24 previous renders leaves exactly enough for one more image.
await app.credits.grant({
customerKey: 'customer',
currencyKey: 'render-credits',
amount: 300,
grantType: 'prepaid',
idempotencyKey: 'paid-render-pack'
});
Prepare the almost-empty wallet
Record the credit cost of 24 earlier renders. This separate test step spends 288 of the purchased 300 credits, leaving exactly twelve for the two competing jobs. It does not run the renderer.
await app.credits.consume({
customerKey: 'customer',
featureKey: 'render',
units: 24,
idempotencyKey: 'completed-render-batch'
});
check(
'Credits before competing jobs',
(await app.credits.getBalance('customer', 'render-credits')).available,
12
);
Output:
Credits before competing jobs: 12
Consume credits before starting a background job
A displayed balance is not a reservation. Each worker must call consume with its own stable job key before starting the renderer. The database-backed debit enforces affordability.
async function admit(job: string) {
if (
!(await app.featureChecker.getValueForCustomer(
'customer',
'roomraster',
'render',
false
))
)
throw new Error('Rendering is not included');
return app.credits.consume({
customerKey: 'customer',
featureKey: 'render',
units: 1,
idempotencyKey: job
});
}
const attempts = await Promise.allSettled([
admit('render-kitchen'),
admit('render-bedroom')
]);
check(
'Accepted jobs',
attempts.filter(r => r.status === 'fulfilled').length,
1
);
check(
'Rejected jobs',
attempts.filter(
r => r.status === 'rejected' && r.reason.name === 'InsufficientCreditsError'
).length,
1
);
check(
'Remaining credits',
(await app.credits.getBalance('customer', 'render-credits')).available,
0
);
Output:
Accepted jobs: 1
Rejected jobs: 1
Remaining credits: 0
Restore credits once after a failed job
Suppose the admitted renderer fails before producing its image. RoomRaster chooses to return twelve credits. That is an application decision, recorded as an adjustment with a correction key distinct from the original debit.
const winner = attempts.findIndex(r => r.status === 'fulfilled');
const failedJob = ['render-kitchen', 'render-bedroom'][winner]!;
const correction = {
customerKey: 'customer',
currencyKey: 'render-credits',
amount: 12,
reason: 'Renderer failed before producing an image',
idempotencyKey: 'correction-' + failedJob
};
await app.credits.adjust(correction);
await app.credits.adjust(correction);
check(
'Credits after repeated correction',
(await app.credits.getBalance('customer', 'render-credits')).available,
12
);
Output:
Credits after repeated correction: 12
Subscrio does not observe the renderer or automatically refund failed work. Persist the job result and correction key in your worker workflow so retries preserve the intended outcome.
Use the result in your application
Only one worker spends the last twelve credits. When that accepted render fails, one explicit adjustment restores them, and retrying the correction leaves the balance unchanged. Keep the debit, external render, and correction as distinct recorded steps in the job lifecycle.
Run the complete RoomRaster example to reproduce the checks. The Subscrio credit entitlements guide explains the related model.
For a related C# example, see prepaid credit packs in C#. A related next step is a shared wallet across TypeScript products.