@nextad/auction 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +0 -0
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/package.json +33 -0
- package/src/Auction.ts +109 -0
- package/src/BidComparator.ts +38 -0
- package/src/BidFactory.ts +32 -0
- package/src/exceptions/index.ts +9 -0
- package/src/index.ts +2 -0
- package/src/types/index.ts +43 -0
- package/test/tsconfig.json +12 -0
- package/test/units/Auction.test.ts +176 -0
- package/tsconfig.json +29 -0
- package/tsup.config.ts +9 -0
- package/vitest.config.ts +14 -0
package/.editorconfig
ADDED
File without changes
|
package/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 NextAd.js
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/package.json
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
{
|
2
|
+
"name": "@nextad/auction",
|
3
|
+
"version": "0.1.1",
|
4
|
+
"description": "",
|
5
|
+
"keywords": [],
|
6
|
+
"author": "Kai Miyamoto",
|
7
|
+
"license": "MIT",
|
8
|
+
"repository": {
|
9
|
+
"type": "git",
|
10
|
+
"url": "https://github.com/nextadjs/auction.git"
|
11
|
+
},
|
12
|
+
"homepage": "https://github.com/nextadjs/auction.git",
|
13
|
+
"devDependencies": {
|
14
|
+
"@nextad/faker": "^0.6.6",
|
15
|
+
"@types/node": "^22.10.7",
|
16
|
+
"tsup": "^8.3.5",
|
17
|
+
"typescript": "^5.7.3",
|
18
|
+
"vitest": "^2.1.8"
|
19
|
+
},
|
20
|
+
"dependencies": {
|
21
|
+
"iab-openrtb": "^0.4.3"
|
22
|
+
},
|
23
|
+
"publishConfig": {
|
24
|
+
"access": "public",
|
25
|
+
"import": "./dist/index.mjs",
|
26
|
+
"require": "./dist/index.js"
|
27
|
+
},
|
28
|
+
"scripts": {
|
29
|
+
"test": "vitest",
|
30
|
+
"build": "tsup"
|
31
|
+
},
|
32
|
+
"types": "./dist/index.d.ts"
|
33
|
+
}
|
package/src/Auction.ts
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
import { BidComparator } from "./BidComparator";
|
2
|
+
import { BidFactory } from "./BidFactory";
|
3
|
+
import {
|
4
|
+
AlreadyEndedAuctionException,
|
5
|
+
BidNotFoundException,
|
6
|
+
} from "./exceptions";
|
7
|
+
import type { BaseBidResponseV26, BidV26, CurrencyConversionData } from "./types";
|
8
|
+
|
9
|
+
type AuctionType = "open" | "closed";
|
10
|
+
|
11
|
+
type AuctionOptions = {
|
12
|
+
lossProcessing?: boolean;
|
13
|
+
currencyConversionData?: CurrencyConversionData;
|
14
|
+
};
|
15
|
+
|
16
|
+
export class Auction {
|
17
|
+
private losingBids: {
|
18
|
+
v26: BidV26[];
|
19
|
+
};
|
20
|
+
private bids: {
|
21
|
+
v26: BidV26[];
|
22
|
+
};
|
23
|
+
|
24
|
+
private itemIds: string[] = [];
|
25
|
+
private currencyConversionData?: CurrencyConversionData;
|
26
|
+
private status: AuctionType;
|
27
|
+
private options: AuctionOptions;
|
28
|
+
|
29
|
+
public constructor(
|
30
|
+
itemOrImpressionIds: string | string[],
|
31
|
+
options: AuctionOptions = {}
|
32
|
+
) {
|
33
|
+
this.options = {
|
34
|
+
lossProcessing: true,
|
35
|
+
...options,
|
36
|
+
};
|
37
|
+
|
38
|
+
this.bids = {
|
39
|
+
v26: [],
|
40
|
+
};
|
41
|
+
this.losingBids = {
|
42
|
+
v26: [],
|
43
|
+
};
|
44
|
+
this.currencyConversionData = options.currencyConversionData;
|
45
|
+
this.status = "open";
|
46
|
+
|
47
|
+
if (typeof itemOrImpressionIds === "string") {
|
48
|
+
this.itemIds.push(itemOrImpressionIds);
|
49
|
+
} else {
|
50
|
+
this.itemIds.push(...itemOrImpressionIds);
|
51
|
+
}
|
52
|
+
}
|
53
|
+
|
54
|
+
public getStatus(): AuctionType {
|
55
|
+
return this.status;
|
56
|
+
}
|
57
|
+
|
58
|
+
public getLosingBids(): { v26: BidV26[] } {
|
59
|
+
return this.losingBids;
|
60
|
+
}
|
61
|
+
|
62
|
+
public placeBidResponseV26(bidResponse: BaseBidResponseV26): this {
|
63
|
+
if (bidResponse.seatbid) {
|
64
|
+
this.bids.v26.push(
|
65
|
+
...BidFactory.createV26Bids(bidResponse, this.itemIds)
|
66
|
+
);
|
67
|
+
}
|
68
|
+
|
69
|
+
return this;
|
70
|
+
}
|
71
|
+
|
72
|
+
public end(): BidV26 {
|
73
|
+
if (this.status !== "open") {
|
74
|
+
throw new AlreadyEndedAuctionException();
|
75
|
+
}
|
76
|
+
|
77
|
+
if (!this.bids.v26.length) {
|
78
|
+
throw new BidNotFoundException();
|
79
|
+
}
|
80
|
+
|
81
|
+
const highestBid = BidComparator.getHighestBidV26(this.bids.v26, this.options.currencyConversionData);
|
82
|
+
|
83
|
+
this.setLosingBids(highestBid);
|
84
|
+
this.handleLossBids();
|
85
|
+
this.status = "closed";
|
86
|
+
|
87
|
+
return highestBid;
|
88
|
+
}
|
89
|
+
|
90
|
+
private setLosingBids(winningBid: BidV26): void {
|
91
|
+
this.losingBids.v26.push(
|
92
|
+
...this.bids.v26.filter((bid) => bid.id !== winningBid.id)
|
93
|
+
);
|
94
|
+
}
|
95
|
+
|
96
|
+
private handleLossBids(): void {
|
97
|
+
this.losingBids.v26.forEach((bid) => {
|
98
|
+
this.handleLossBid(bid);
|
99
|
+
});
|
100
|
+
}
|
101
|
+
|
102
|
+
private handleLossBid(bid: BidV26): void {
|
103
|
+
if (this.options.lossProcessing && bid.lurl) {
|
104
|
+
fetch(bid.lurl, {
|
105
|
+
keepalive: true,
|
106
|
+
});
|
107
|
+
}
|
108
|
+
}
|
109
|
+
}
|
@@ -0,0 +1,38 @@
|
|
1
|
+
import type { BidV26, CurrencyConversionData } from "./types";
|
2
|
+
|
3
|
+
export class BidComparator {
|
4
|
+
public static getHighestBidV26(
|
5
|
+
bids: BidV26[],
|
6
|
+
currencyConversionData?: CurrencyConversionData
|
7
|
+
): BidV26 {
|
8
|
+
if (!currencyConversionData) {
|
9
|
+
return bids.reduce((highest, current) => {
|
10
|
+
const highestPrice = highest.price * 100;
|
11
|
+
const currentPrice = current.price * 100;
|
12
|
+
return currentPrice > highestPrice ? current : highest;
|
13
|
+
});
|
14
|
+
}
|
15
|
+
|
16
|
+
return bids.reduce((highest, current) => {
|
17
|
+
const highestPrice = this.convertPrice(highest, currencyConversionData);
|
18
|
+
const currentPrice = this.convertPrice(current, currencyConversionData);
|
19
|
+
return currentPrice > highestPrice ? current : highest;
|
20
|
+
});
|
21
|
+
}
|
22
|
+
|
23
|
+
private static convertPrice(
|
24
|
+
bid: BidV26,
|
25
|
+
conversionData: CurrencyConversionData
|
26
|
+
): number {
|
27
|
+
const price = bid.price;
|
28
|
+
const rates = conversionData.conversions[bid.ext.nextadjs.currency];
|
29
|
+
|
30
|
+
if (!rates) {
|
31
|
+
return price * 100;
|
32
|
+
}
|
33
|
+
|
34
|
+
const targetCurrency = "USD";
|
35
|
+
const rate = rates[targetCurrency];
|
36
|
+
return price * (rate || 1) * 100;
|
37
|
+
}
|
38
|
+
}
|
@@ -0,0 +1,32 @@
|
|
1
|
+
import type { BaseBidResponseV26, BidV26 } from "./types";
|
2
|
+
|
3
|
+
export class BidFactory {
|
4
|
+
public static createV26Bids(
|
5
|
+
bidResponse: BaseBidResponseV26,
|
6
|
+
itemIds: string[]
|
7
|
+
): BidV26[] {
|
8
|
+
let bids: BidV26[] = [];
|
9
|
+
|
10
|
+
if (bidResponse.seatbid) {
|
11
|
+
bidResponse.seatbid.forEach((seatBid) => {
|
12
|
+
seatBid.bid.forEach((bid) => {
|
13
|
+
if (itemIds.some((itemId) => bid.impid === itemId)) {
|
14
|
+
bids.push({
|
15
|
+
...bid,
|
16
|
+
ext: {
|
17
|
+
...bid.ext,
|
18
|
+
nextadjs: {
|
19
|
+
openrtbVersion: "2.6",
|
20
|
+
currency: bidResponse.cur || "USD",
|
21
|
+
seat: seatBid.seat,
|
22
|
+
},
|
23
|
+
},
|
24
|
+
});
|
25
|
+
}
|
26
|
+
});
|
27
|
+
});
|
28
|
+
}
|
29
|
+
|
30
|
+
return bids;
|
31
|
+
}
|
32
|
+
}
|
package/src/index.ts
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
import type {
|
2
|
+
Bid as BaseBidV26,
|
3
|
+
BidResponse as BaseBidResponseV26,
|
4
|
+
SeatBid as BaseSeatBidV26,
|
5
|
+
} from "iab-openrtb/v26";
|
6
|
+
export type {
|
7
|
+
BidResponse as BaseBidResponseV26,
|
8
|
+
Bid as BaseBidV26,
|
9
|
+
} from "iab-openrtb/v26";
|
10
|
+
|
11
|
+
export interface BidResponseV26 extends BaseBidResponseV26 {
|
12
|
+
seatbid?: SeatBidV26[];
|
13
|
+
}
|
14
|
+
export interface SeatBidV26 extends BaseSeatBidV26 {
|
15
|
+
bid: BidV26[];
|
16
|
+
}
|
17
|
+
|
18
|
+
export interface BidV26 extends BaseBidV26 {
|
19
|
+
ext: {
|
20
|
+
nextadjs: {
|
21
|
+
openrtbVersion: "2.6";
|
22
|
+
seat?: string;
|
23
|
+
currency: string;
|
24
|
+
};
|
25
|
+
};
|
26
|
+
}
|
27
|
+
|
28
|
+
|
29
|
+
export interface CurrencyRates {
|
30
|
+
/** ISO 4217 Currency Code (Example: USD, JPY, EUR) */
|
31
|
+
[currency: string]: number;
|
32
|
+
}
|
33
|
+
|
34
|
+
interface ConversionRates {
|
35
|
+
/** convert rate of base currency */
|
36
|
+
[baseCurrency: string]: CurrencyRates;
|
37
|
+
}
|
38
|
+
|
39
|
+
export interface CurrencyConversionData {
|
40
|
+
dataAsOf: string;
|
41
|
+
generatedAt: string;
|
42
|
+
conversions: ConversionRates;
|
43
|
+
}
|
@@ -0,0 +1,176 @@
|
|
1
|
+
import { openrtbFaker } from "@nextad/faker";
|
2
|
+
import { Auction } from "@/Auction";
|
3
|
+
import {
|
4
|
+
AlreadyEndedAuctionException,
|
5
|
+
BidNotFoundException,
|
6
|
+
} from "@/exceptions";
|
7
|
+
|
8
|
+
describe("Auction", () => {
|
9
|
+
it("returns highest bid from bids with specified item(impression) when auction ends", () => {
|
10
|
+
const sut = new Auction("impid-1");
|
11
|
+
const bidResponse = openrtbFaker.v26.bidResponse
|
12
|
+
.addBannerBid({ impid: "impid-1", price: 10 })
|
13
|
+
.make();
|
14
|
+
const bidResponse2 = openrtbFaker.v26.bidResponse
|
15
|
+
.addBannerBid({ impid: "impid-1", price: 20 })
|
16
|
+
.make();
|
17
|
+
sut.placeBidResponseV26(bidResponse);
|
18
|
+
sut.placeBidResponseV26(bidResponse2);
|
19
|
+
|
20
|
+
const result = sut.end();
|
21
|
+
|
22
|
+
expect(result.id).toBe(bidResponse2.seatbid![0].bid[0].id);
|
23
|
+
});
|
24
|
+
|
25
|
+
it("returns highest bid from bids with specified items(impressions) when auction ends", () => {
|
26
|
+
const sut = new Auction(["impid-1", "impid-2"]);
|
27
|
+
const bidResponse = openrtbFaker.v26.bidResponse
|
28
|
+
.addBannerBid({ impid: "impid-1", price: 10 })
|
29
|
+
.make();
|
30
|
+
const bidResponse2 = openrtbFaker.v26.bidResponse
|
31
|
+
.addBannerBid({ impid: "impid-1", price: 20 })
|
32
|
+
.addVideoBid({ impid: "impid-2", price: 30 })
|
33
|
+
.make();
|
34
|
+
sut.placeBidResponseV26(bidResponse);
|
35
|
+
sut.placeBidResponseV26(bidResponse2);
|
36
|
+
|
37
|
+
const result = sut.end();
|
38
|
+
|
39
|
+
expect(result.id).toBe(bidResponse2.seatbid![0].bid[1].id);
|
40
|
+
});
|
41
|
+
|
42
|
+
it("returns highest bid considering currency when auction ends", () => {
|
43
|
+
const sut = new Auction("impid-1", {
|
44
|
+
currencyConversionData: {
|
45
|
+
dataAsOf: "2021-01-01",
|
46
|
+
generatedAt: "2021-01-01",
|
47
|
+
conversions: {
|
48
|
+
JPY: {
|
49
|
+
USD: 0.09,
|
50
|
+
},
|
51
|
+
},
|
52
|
+
},
|
53
|
+
});
|
54
|
+
const bidResponse = openrtbFaker.v26.bidResponse
|
55
|
+
.withCurrency("JPY")
|
56
|
+
.addBannerBid({ impid: "impid-1", price: 10 })
|
57
|
+
.make();
|
58
|
+
const bidResponse2 = openrtbFaker.v26.bidResponse
|
59
|
+
.withCurrency("USD")
|
60
|
+
.addBannerBid({ impid: "impid-1", price: 5 })
|
61
|
+
.make();
|
62
|
+
sut.placeBidResponseV26(bidResponse);
|
63
|
+
sut.placeBidResponseV26(bidResponse2);
|
64
|
+
|
65
|
+
const result = sut.end();
|
66
|
+
|
67
|
+
expect(result.id).toBe(bidResponse2.seatbid![0].bid[0].id);
|
68
|
+
});
|
69
|
+
|
70
|
+
it("throws error when attempting to end auction without bids", () => {
|
71
|
+
const sut = new Auction("impid-1");
|
72
|
+
|
73
|
+
expect(() => sut.end()).toThrow(BidNotFoundException);
|
74
|
+
});
|
75
|
+
it("returns losing bids when auction ends", () => {
|
76
|
+
const sut = new Auction(["impid-1", "impid-2"]);
|
77
|
+
const bidResponse = openrtbFaker.v26.bidResponse
|
78
|
+
.addBannerBid({
|
79
|
+
impid: "impid-1",
|
80
|
+
price: 10,
|
81
|
+
})
|
82
|
+
.addBannerBid({
|
83
|
+
impid: "impid-2",
|
84
|
+
price: 20,
|
85
|
+
})
|
86
|
+
.make();
|
87
|
+
sut.placeBidResponseV26(bidResponse);
|
88
|
+
|
89
|
+
sut.end();
|
90
|
+
|
91
|
+
const losingBids = sut.getLosingBids();
|
92
|
+
expect(losingBids.v26.length).toBe(1);
|
93
|
+
expect(losingBids.v26[0].id).toBe(bidResponse.seatbid![0].bid[0].id);
|
94
|
+
});
|
95
|
+
|
96
|
+
it("throws error when attempting to already ended auction", () => {
|
97
|
+
const sut = new Auction("impid-1");
|
98
|
+
const bidResponse = openrtbFaker.v26.bidResponse
|
99
|
+
.addBannerBid({ impid: "impid-1", price: 10 })
|
100
|
+
.make();
|
101
|
+
sut.placeBidResponseV26(bidResponse);
|
102
|
+
sut.end();
|
103
|
+
|
104
|
+
expect(() => sut.end()).toThrow(AlreadyEndedAuctionException);
|
105
|
+
});
|
106
|
+
|
107
|
+
it("sends loss notification when bid contains loss notice url", () => {
|
108
|
+
const fetchMock = vi.fn();
|
109
|
+
vi.stubGlobal("fetch", fetchMock);
|
110
|
+
const sut = new Auction(["impid-1", "impid-2"]);
|
111
|
+
const bidResponse = openrtbFaker.v26.bidResponse
|
112
|
+
.addBannerBid({
|
113
|
+
impid: "impid-1",
|
114
|
+
price: 10,
|
115
|
+
lurl: "https://example.com/lurl",
|
116
|
+
})
|
117
|
+
.addBannerBid({
|
118
|
+
impid: "impid-2",
|
119
|
+
price: 20,
|
120
|
+
})
|
121
|
+
.make();
|
122
|
+
sut.placeBidResponseV26(bidResponse);
|
123
|
+
|
124
|
+
sut.end();
|
125
|
+
|
126
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
127
|
+
expect(fetchMock).toHaveBeenCalledWith("https://example.com/lurl", {
|
128
|
+
keepalive: true,
|
129
|
+
});
|
130
|
+
});
|
131
|
+
|
132
|
+
it("does not send loss notification when bid does not contain loss notice url", () => {
|
133
|
+
const fetchMock = vi.fn();
|
134
|
+
vi.stubGlobal("fetch", fetchMock);
|
135
|
+
const sut = new Auction(["impid-1", "impid-2"]);
|
136
|
+
const bidResponse = openrtbFaker.v26.bidResponse
|
137
|
+
.addBannerBid({
|
138
|
+
impid: "impid-1",
|
139
|
+
price: 10,
|
140
|
+
})
|
141
|
+
.addBannerBid({
|
142
|
+
impid: "impid-2",
|
143
|
+
price: 20,
|
144
|
+
})
|
145
|
+
.make();
|
146
|
+
sut.placeBidResponseV26(bidResponse);
|
147
|
+
|
148
|
+
sut.end();
|
149
|
+
|
150
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
151
|
+
});
|
152
|
+
|
153
|
+
it("does not send loss notification when loss processing is specified as false", () => {
|
154
|
+
const fetchMock = vi.fn();
|
155
|
+
vi.stubGlobal("fetch", fetchMock);
|
156
|
+
const sut = new Auction(["impid-1", "impid-2"], {
|
157
|
+
lossProcessing: false,
|
158
|
+
});
|
159
|
+
const bidResponse = openrtbFaker.v26.bidResponse
|
160
|
+
.addBannerBid({
|
161
|
+
impid: "impid-1",
|
162
|
+
price: 10,
|
163
|
+
lurl: "https://example.com/lurl",
|
164
|
+
})
|
165
|
+
.addBannerBid({
|
166
|
+
impid: "impid-2",
|
167
|
+
price: 20,
|
168
|
+
})
|
169
|
+
.make();
|
170
|
+
sut.placeBidResponseV26(bidResponse);
|
171
|
+
|
172
|
+
sut.end();
|
173
|
+
|
174
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
175
|
+
});
|
176
|
+
});
|
package/tsconfig.json
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
{
|
2
|
+
"compilerOptions": {
|
3
|
+
"target": "es2022",
|
4
|
+
"lib": ["es2022", "esnext.disposable", "dom"],
|
5
|
+
|
6
|
+
"module": "ESNext",
|
7
|
+
"moduleDetection": "force",
|
8
|
+
"allowJs": true,
|
9
|
+
|
10
|
+
"moduleResolution": "Bundler",
|
11
|
+
"allowImportingTsExtensions": true,
|
12
|
+
"verbatimModuleSyntax": true,
|
13
|
+
"isolatedModules": true,
|
14
|
+
"preserveConstEnums": true,
|
15
|
+
"noEmit": true,
|
16
|
+
|
17
|
+
"skipLibCheck": true,
|
18
|
+
"strict": true,
|
19
|
+
"noFallthroughCasesInSwitch": true,
|
20
|
+
"forceConsistentCasingInFileNames": true,
|
21
|
+
|
22
|
+
"baseUrl": ".",
|
23
|
+
"paths": {
|
24
|
+
"@/*": ["src/*"]
|
25
|
+
}
|
26
|
+
},
|
27
|
+
"include": ["**/*.ts"],
|
28
|
+
"exclude": ["node_modules"]
|
29
|
+
}
|
package/tsup.config.ts
ADDED
package/vitest.config.ts
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
2
|
+
import path from "node:path";
|
3
|
+
import { defineConfig } from "vitest/config";
|
4
|
+
|
5
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
6
|
+
|
7
|
+
export default defineConfig({
|
8
|
+
test: {
|
9
|
+
globals: true,
|
10
|
+
alias: {
|
11
|
+
"@": path.resolve(__dirname, "src"),
|
12
|
+
},
|
13
|
+
},
|
14
|
+
});
|