CluPilotCloud/tests/Feature/Billing/PlanCatalogueTest.php

524 lines
25 KiB
PHP

<?php
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\MultipleRecordsFoundException;
/**
* The catalogue moved out of config and into three tables. This is the shadow
* comparison the move was supposed to survive: the values the seeded catalogue
* serves are pinned literally, so a migration that quietly changes what we sell
* fails here rather than in a customer's invoice.
*/
/** What config/provisioning.php sold, before the catalogue became a table. */
const CATALOGUE_BEFORE_THE_MOVE = [
'start' => ['tier' => 1, 'traffic_gb' => 1000, 'quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 5, 'performance' => 'standard', 'price_cents' => 4900, 'template_vmid' => 9000],
'team' => ['tier' => 2, 'traffic_gb' => 3000, 'quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'performance' => 'enhanced', 'price_cents' => 17900, 'template_vmid' => 9000],
'business' => ['tier' => 3, 'traffic_gb' => 8000, 'quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'seats' => 100, 'performance' => 'high', 'price_cents' => 39900, 'template_vmid' => 9000],
'enterprise' => ['tier' => 4, 'traffic_gb' => 20000, 'quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'seats' => 500, 'performance' => 'dedicated', 'price_cents' => 79900, 'template_vmid' => 9000],
];
it('sells exactly what the config catalogue sold', function () {
$sellable = app(PlanCatalogue::class)->sellable();
expect(array_keys($sellable))->toBe(array_keys(CATALOGUE_BEFORE_THE_MOVE));
foreach (CATALOGUE_BEFORE_THE_MOVE as $key => $expected) {
foreach ($expected as $field => $value) {
expect($sellable[$key][$field])->toBe($value, "{$key}.{$field}");
}
}
});
it('freezes the same snapshot the config catalogue would have frozen', function () {
foreach (CATALOGUE_BEFORE_THE_MOVE as $key => $expected) {
$snapshot = Subscription::snapshotFrom($key);
expect($snapshot['price_cents'])->toBe($expected['price_cents'])
->and($snapshot['ram_mb'])->toBe($expected['ram_mb'])
->and($snapshot['cores'])->toBe($expected['cores'])
->and($snapshot['disk_gb'])->toBe($expected['disk_gb'])
->and($snapshot['quota_gb'])->toBe($expected['quota_gb'])
->and($snapshot['traffic_gb'])->toBe($expected['traffic_gb'])
->and($snapshot['seats'])->toBe($expected['seats'])
->and($snapshot['tier'])->toBe($expected['tier'])
->and($snapshot['template_vmid'])->toBe($expected['template_vmid'])
->and($snapshot['plan_version_id'])->not->toBeNull();
// Yearly is twelve months of the same price until the owner says
// otherwise — and it comes from its own row, not from arithmetic.
expect(Subscription::snapshotFrom($key, 'yearly')['price_cents'])
->toBe($expected['price_cents'] * 12);
}
});
it('hides a plan the shop cannot price for every term it sells', function () {
$catalogue = app(PlanCatalogue::class);
// Forced past the guard, the way a bad repair script would.
PlanPrice::query()->whereKey($catalogue->currentVersion('team')->priceFor('yearly')->id)->delete();
// The shop and the checkout have to agree, or a customer picks a plan,
// pays, and lands on a contract that cannot be opened.
expect($catalogue->isSellable('team'))->toBeFalse()
->and(array_keys($catalogue->sellable()))->not->toContain('team')
->and(array_keys($catalogue->sellable()))->toContain('start');
});
it('records which version a contract was sold under', function () {
$snapshot = Subscription::snapshotFrom('team');
$version = app(PlanCatalogue::class)->version($snapshot['plan_version_id']);
expect($version->family->key)->toBe('team')
->and($version->version)->toBe(1);
});
it('contracts a checkout to the version it was shown, not the one that replaced it', function () {
$catalogue = app(PlanCatalogue::class);
$shown = $catalogue->currentVersion('team');
$currency = Subscription::catalogueCurrency();
// The customer is looking at v1 when the owner's scheduled swap lands.
$catalogue->schedule($shown, $shown->available_from, now());
$successor = PlanVersion::query()->create([
...$shown->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2, 'features' => $shown->features, 'available_from' => now(),
]);
$successor->prices()->create(['term' => 'monthly', 'amount_cents' => 24900, 'currency' => $currency]);
$successor->prices()->create(['term' => 'yearly', 'amount_cents' => 298800, 'currency' => $currency]);
$catalogue->publish($successor, now());
// Their payment arrives after the swap. They pay what they were quoted.
$snapshot = Subscription::snapshotFrom('team', 'monthly', $shown->id);
expect($snapshot['price_cents'])->toBe(17900)
->and($snapshot['plan_version_id'])->toBe($shown->id);
// Someone arriving now gets the successor.
expect(Subscription::snapshotFrom('team')['price_cents'])->toBe(24900);
});
it('fixes the price of a published version, so a checkout cannot be repriced mid-flight', function () {
$price = app(PlanCatalogue::class)->currentVersion('team')->priceFor('monthly');
expect(fn () => $price->update(['amount_cents' => 24900]))
->toThrow(RuntimeException::class, 'fixed');
expect($price->fresh()->amount_cents)->toBe(17900);
// Stripe's id is filled in afterwards, so that stays writable. Re-read
// first: the refused edit is still sitting on the rejected object, and the
// guard would rightly stop it riding along on the next save.
$price->refresh()->update(['stripe_price_id' => 'price_123']);
expect($price->fresh()->stripe_price_id)->toBe('price_123')
->and($price->fresh()->amount_cents)->toBe(17900);
});
it('publishes a version once, even if two attempts arrive together', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
$currency = Subscription::catalogueCurrency();
$live = $catalogue->currentVersion('team');
$catalogue->schedule($live, $live->available_from, now());
$draft = $catalogue->draft($family, [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [],
], ['monthly' => 18900, 'yearly' => 226800]);
$catalogue->publish($draft, now(), now()->addYear());
// A second attempt on the same version must not sail past and overwrite the
// window the first one just set.
$stale = PlanVersion::query()->findOrFail($draft->id);
$stale->published_at = null; // as a racing request would still see it
expect(fn () => $catalogue->publish($stale, now(), null))
->toThrow(RuntimeException::class, 'already published');
expect($draft->fresh()->available_until)->not->toBeNull();
});
it('numbers drafts in sequence without colliding', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
$capabilities = [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [],
];
$first = $catalogue->draft($family, $capabilities, ['monthly' => 18900, 'yearly' => 226800]);
$second = $catalogue->draft($family, $capabilities, ['monthly' => 19900, 'yearly' => 238800]);
expect($first->version)->toBe(2)
->and($second->version)->toBe(3)
->and($second->priceFor('monthly')->amount_cents)->toBe(19900);
});
it('refuses to delete a published version or its price, but lets a draft go', function () {
$published = app(PlanCatalogue::class)->currentVersion('team');
expect(fn () => $published->delete())->toThrow(RuntimeException::class, 'cannot be deleted')
->and(fn () => $published->priceFor('monthly')->delete())->toThrow(RuntimeException::class, 'cannot be deleted');
// Contracts point at the version to record what was sold; deleting it would
// null that away and leave a customer contracted to nothing.
$subscription = Subscription::factory()->plan('team')->create();
expect($subscription->plan_version_id)->toBe($published->id);
$draft = PlanVersion::query()->create([
...$published->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 9, 'features' => [], 'available_from' => now()->addYear(),
]);
// Nothing was ever promised on a draft, so it can simply go.
expect($draft->delete())->toBeTrue();
});
it('will not let a plan family be renamed or deleted out from under its customers', function () {
$family = PlanFamily::query()->where('key', 'team')->sole();
// Orders, instances and every snapshot store this string.
expect(fn () => $family->update(['key' => 'teams']))->toThrow(RuntimeException::class, 'permanent');
// Deleting would cascade past the version guard and null the provenance
// off every contract on it.
expect(fn () => $family->fresh()->delete())->toThrow(RuntimeException::class, 'cannot be deleted');
expect(PlanFamily::query()->where('key', 'team')->exists())->toBeTrue();
// The display name is what is meant to change.
$family->fresh()->update(['name' => 'Team Pro']);
expect(PlanFamily::query()->where('key', 'team')->sole()->name)->toBe('Team Pro');
});
it('lets a family that never sold anything be removed with its drafts', function () {
$family = PlanFamily::query()->create(['key' => 'trial', 'name' => 'Trial', 'tier' => 0]);
$draft = $family->versions()->create([
'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024,
'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [],
'available_from' => now(),
]);
expect($family->delete())->toBeTrue()
->and(PlanVersion::query()->whereKey($draft->id)->exists())->toBeFalse();
});
it('still honours a quoted version after the plan is withdrawn', function () {
$catalogue = app(PlanCatalogue::class);
$quoted = $catalogue->currentVersion('team');
// The owner pulls Team while a customer is at the checkout.
PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]);
expect($catalogue->isSellable('team'))->toBeFalse()
// They saw it and paid for it, so it is still deliverable to them.
->and($catalogue->isDeliverable('team', $quoted->id))->toBeTrue()
->and(Subscription::snapshotFrom('team', 'monthly', $quoted->id)['price_cents'])->toBe(17900);
});
it('will not contract a customer to a version of a different plan', function () {
$startVersion = app(PlanCatalogue::class)->currentVersion('start');
expect(fn () => Subscription::snapshotFrom('team', 'monthly', $startVersion->id))
->toThrow(RuntimeException::class, 'belongs to');
});
it('refuses to sell a plan the owner has switched off', function () {
PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]);
expect(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse()
->and(array_keys(app(PlanCatalogue::class)->sellable()))->not->toContain('team');
// Fails closed rather than falling back to anything.
expect(fn () => Subscription::snapshotFrom('team'))->toThrow(RuntimeException::class);
});
it('does not sell a version before its window opens or after it closes', function () {
$catalogue = app(PlanCatalogue::class);
$version = $catalogue->currentVersion('team');
$catalogue->schedule($version, now()->addWeek(), now()->addMonth());
expect($catalogue->isSellable('team'))->toBeFalse()
->and($catalogue->isSellable('team', now()->addWeek()))->toBeTrue()
// Half-open: the closing moment is the first at which it is gone.
->and($catalogue->isSellable('team', now()->addMonth()))->toBeFalse()
->and($catalogue->isSellable('team', now()->addMonth()->subSecond()))->toBeTrue();
});
it('crashes instead of choosing between two versions on sale at once', function () {
$family = PlanFamily::query()->where('key', 'team')->sole();
$first = $family->versions()->sole();
// Forced past the guard, the way a bad migration or a direct edit would.
PlanVersion::query()->create([
...$first->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2,
'features' => $first->features,
'available_from' => now()->subDay(),
'available_until' => null,
'published_at' => now()->subDay(),
]);
expect(fn () => app(PlanCatalogue::class)->currentVersion('team'))
->toThrow(MultipleRecordsFoundException::class);
});
it('refuses a window that overlaps another version of the same plan', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
$first = $catalogue->currentVersion('team');
// Close the running version, then publish a successor after it.
$catalogue->schedule($first, $first->available_from, now()->addMonth());
$second = PlanVersion::query()->create([
...$first->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2,
'features' => $first->features,
'available_from' => now()->addMonth(),
]);
$second->prices()->create([
'term' => 'monthly', 'amount_cents' => 19900, 'currency' => Subscription::catalogueCurrency(),
]);
$second->prices()->create([
'term' => 'yearly', 'amount_cents' => 238800, 'currency' => Subscription::catalogueCurrency(),
]);
$catalogue->publish($second, now()->addMonth());
// Backdating the successor into its predecessor's window is refused.
expect(fn () => $catalogue->schedule($second, now()->addWeek()))
->toThrow(RuntimeException::class);
// Starting exactly where the predecessor stops is not an overlap.
$catalogue->schedule($second, now()->addMonth());
expect($catalogue->currentVersion('team', now()->addMonth())->version)->toBe(2);
});
it('locks a version once it is published, but still lets its window move', function () {
$version = app(PlanCatalogue::class)->currentVersion('team');
expect(fn () => $version->update(['ram_mb' => 4096]))->toThrow(RuntimeException::class);
// The refused edit is not smuggled through by the next write either.
app(PlanCatalogue::class)->schedule($version, $version->available_from, now()->addYear());
expect($version->fresh()->available_until)->not->toBeNull()
->and($version->fresh()->ram_mb)->toBe(8192);
});
it('lets an unpublished draft be edited freely', function () {
$family = PlanFamily::query()->where('key', 'team')->sole();
$draft = PlanVersion::query()->create([
'plan_family_id' => $family->id, 'version' => 2,
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => ['monitoring'], 'available_from' => now()->addMonth(),
]);
$draft->update(['ram_mb' => 12288]);
expect($draft->fresh()->ram_mb)->toBe(12288);
});
it('will not sell a version that provisioning could not build', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
$draft = $catalogue->draft($family, [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced',
'template_vmid' => null, // no blueprint
'features' => [],
], ['monthly' => 18900, 'yearly' => 226800]);
// Otherwise it goes on sale, a customer pays, and the pipeline stops at
// CloneVirtualMachine with template_missing.
expect(fn () => $catalogue->publish($draft, now()))
->toThrow(RuntimeException::class, 'VM template');
});
it('will not publish a version that is not priced for every term', function () {
$family = PlanFamily::query()->where('key', 'team')->sole();
$draft = PlanVersion::query()->create([
'plan_family_id' => $family->id, 'version' => 3,
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [], 'available_from' => now(),
]);
expect(fn () => app(PlanCatalogue::class)->publish($draft))->toThrow(RuntimeException::class);
// Yearly alone is not enough: someone would pick monthly and hit a
// checkout that cannot price them.
$draft->prices()->create(['term' => 'yearly', 'amount_cents' => 214800, 'currency' => Subscription::catalogueCurrency()]);
expect(fn () => app(PlanCatalogue::class)->publish($draft))->toThrow(RuntimeException::class, 'monthly');
});
it('leaves the version unpublished and its predecessor untouched when the window is still refused', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
$currency = Subscription::catalogueCurrency();
// v1 runs until next month, when the already-published v2 takes over.
$v1 = $catalogue->currentVersion('team');
$catalogue->schedule($v1, $v1->available_from, now()->addMonth());
$closesAt = $v1->fresh()->available_until->toDateTimeString();
$v2 = PlanVersion::query()->create([
...$v1->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2, 'features' => $v1->features, 'available_from' => now()->addMonth(),
]);
$v2->prices()->create(['term' => 'monthly', 'amount_cents' => 18900, 'currency' => $currency]);
$v2->prices()->create(['term' => 'yearly', 'amount_cents' => 226800, 'currency' => $currency]);
$catalogue->publish($v2, now()->addMonth());
$draft = PlanVersion::query()->create([
'plan_family_id' => $family->id, 'version' => 4,
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [], 'available_from' => now(),
]);
$draft->prices()->create(['term' => 'monthly', 'amount_cents' => 19900, 'currency' => $currency]);
$draft->prices()->create(['term' => 'yearly', 'amount_cents' => 238800, 'currency' => $currency]);
// Open-ended from now: v1 would be handed over, but v2 is still in the way,
// and a handover is not licence to run over a scheduled successor.
expect(fn () => $catalogue->publish($draft, now()))->toThrow(RuntimeException::class, 'overlaps');
// Still a draft, so the mistake is simply correctable — publishing first
// and failing after would have frozen it beyond repair. And v1 keeps the
// window it had: a predecessor closed for a successor that never arrived
// would be a plan taken off sale by an action that failed.
expect($draft->fresh()->isPublished())->toBeFalse()
->and($draft->fresh()->available_from->toDateString())->toBe(now()->toDateString())
->and($v1->fresh()->available_until->toDateTimeString())->toBe($closesAt);
// A window that fits between the two is accepted, and takes v1 over then.
$catalogue->publish($draft, now()->addWeek(), now()->addMonth());
expect($catalogue->currentVersion('team', now()->addWeek())->version)->toBe(4)
->and($v1->fresh()->available_until->eq($draft->fresh()->available_from))->toBeTrue();
});
it('hands the running version over to its replacement instead of refusing to publish it', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
$running = $catalogue->currentVersion('team');
$at = now()->addWeek();
$successor = $catalogue->draft($family, [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [],
], ['monthly' => 18900, 'yearly' => 226800]);
// No closing the old one first: that used to be mandatory, and a
// replacement that then failed to publish left the plan off sale for good.
$catalogue->publish($successor, $at);
// The predecessor stops exactly where the successor starts.
expect($running->fresh()->available_until->eq($successor->fresh()->available_from))->toBeTrue();
// currentVersion() uses sole(), so each of these also proves that exactly
// one version is on sale at that moment — and there is no gap at the seam.
expect($catalogue->currentVersion('team', $at->copy()->subSecond())->version)->toBe($running->version)
->and($catalogue->currentVersion('team', $at)->version)->toBe($successor->version)
->and($catalogue->isSellable('team', $at->copy()->subSecond()))->toBeTrue()
->and($catalogue->isSellable('team', $at))->toBeTrue()
->and($catalogue->sellable($at)['team']['price_cents'])->toBe(18900);
});
it('leaves a version that had already ended out of the handover', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
// v1 stops in a minute, and nothing replaces it for a week.
$ended = $catalogue->currentVersion('team');
$catalogue->schedule($ended, $ended->available_from, now()->addMinute());
$endedAt = $ended->fresh()->available_until->toDateTimeString();
$successor = $catalogue->draft($family, [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [],
], ['monthly' => 18900, 'yearly' => 226800]);
$catalogue->publish($successor, now()->addWeek());
// The handover closes what is running, not what is over. Moving this one's
// end date forward would rewrite when it actually stopped selling.
expect($ended->fresh()->available_until->toDateTimeString())->toBe($endedAt);
});
it('puts a closed version back on sale', function () {
$catalogue = app(PlanCatalogue::class);
$version = $catalogue->currentVersion('team');
$catalogue->schedule($version, $version->available_from, now());
expect($catalogue->isSellable('team'))->toBeFalse();
$catalogue->reopen($version);
// Open-ended again, not merely extended: the plan sells now and keeps
// selling until someone decides otherwise.
expect($catalogue->isSellable('team'))->toBeTrue()
->and($catalogue->isSellable('team', now()->addYear()))->toBeTrue()
->and($catalogue->sellable()['team']['price_cents'])->toBe(17900)
->and($catalogue->currentVersion('team')->version)->toBe($version->version);
});
it('refuses to reopen a version whose successor is already selling', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
$first = $catalogue->currentVersion('team');
$second = $catalogue->draft($family, [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [],
], ['monthly' => 18900, 'yearly' => 226800]);
$catalogue->publish($second, now());
// Reopening would put both on sale, and every read of the family would
// throw rather than pick one.
expect(fn () => $catalogue->reopen($first))->toThrow(RuntimeException::class, 'overlaps');
expect($catalogue->currentVersion('team')->version)->toBe($second->version);
});
it('refuses to reopen a version that was never published', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
$draft = $catalogue->draft($family, [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [],
], ['monthly' => 18900, 'yearly' => 226800]);
// Nothing was ever on sale, so there is nothing to put back.
expect(fn () => $catalogue->reopen($draft))->toThrow(RuntimeException::class, 'never published');
expect($draft->fresh()->isPublished())->toBeFalse();
});
it('reports a consistent catalogue, and an inconsistent one', function () {
$this->artisan('plans:check')->assertSuccessful();
PlanVersion::query()->whereKey(app(PlanCatalogue::class)->currentVersion('team')->id)
->update(['available_until' => now()->subDay()]);
$this->artisan('plans:check')->assertFailed();
});