이코노미 생성하기 #

이 가이드에서는 Nakama의 IAP 유효성 검사, 가상 지갑저장소 엔진 기능을 사용하여 게임 내 이코노미 시스템을 개발할 수 있는 방법에 대해서 설명합니다.

이 가이드에서 플레이어는 IAP를 통해 프리미엄 화폐와 보석을 구매할 수 있습니다. 보석으로 게임 아이템을 구매할 수 있는 코인을 구매할 수 있습니다.

플레이어는 IAP를 사용하여 프리미엄 플레이어가 될 수 있고 필요한 경우 구매 내용을 향후에 복구할 수 있습니다.

IAP로 프리미엄 화폐 구매하기 #

다음의 서버 런타임 코드 예시는 Unity IAP 패키지를 사용하여 구매 영수증을 사용자 지정 Nakama RPC로 제출한다고 가정합니다.

이 RPC는 구매 시 어떤 앱 스토어를 사용했는지 페이로드를 확인하고, 적절한 앱 스토어로 구매에 대한 유효성을 검사합니다. 구매가 유효한 경우, 각각의 유효한 구매에 대해서 별도의 기능을 호출하여 구매 제품 ID를 확인하고 적절한 양의 보석을 플레이어의 가상 지갑으로 지급합니다.

Nakama로 구매에 대한 유효성을 검사하려면 각각의 앱 스토어에 맞는 구성 변수를 제공해야 합니다.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
let RpcValidateIAP: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
  // Assumes payload is a Unity IAP receipt
  const iap = JSON.parse(payload);

  // Validate the purchases depending on which app store was used
  let validatePurchaseResponse : nkruntime.ValidatePurchaseResponse;
  switch (iap.store) {
    case 'GooglePlay':
      validatePurchaseResponse = nk.purchaseValidateGoogle(ctx.userId, iap.payload);
      break;
    case 'AppleAppStore':
      validatePurchaseResponse = nk.purchaseValidateApple(ctx.userId, iap.payload);
      break;
    default:
      logger.warn('Unrecognised app store in payload')
      return JSON.stringify({ success: false });
      break;
  }

  validatePurchaseResponse.validatedPurchases.forEach(p => rewardPurchase(ctx, logger, nk, p));
  return JSON.stringify({ success: true });
};

let rewardPurchase = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, validatedPurchase: nkruntime.ValidatedPurchase): void {
  // Here we are just dealing with consumable IAPs
  switch (validatedPurchase.productId) {
    case 'gems_100':
      nk.walletUpdate(ctx.userId, { gems: 100 }, null, true);
      break;
    case 'gems_1000':
      nk.walletUpdate(ctx.userId, { gems: 1000 }, null, true);
      break;
  }
};
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
type IAPPayload struct {
	Store     string `json:"store"`
	ProductId int    `json:"productId"`
	Payload   string `json:"payload"`
}

func RpcValidateAPI(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// Get the user ID
	userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok {
		logger.Error("no user id found")
		return "", runtime.NewError("no user id found", 16)
	}

	// Assumes payload is a Unity IAP receipt
	iap := &IAPPayload{}
	if err := json.Unmarshal([]byte(payload), iap); err != nil {
		logger.Error("error unmarshaling payload")
		return "", runtime.NewError("iap payload invalid", 3)
	}

	// Validate the purchases depending on which app store was used
	switch iap.Store {
	case "GooglePlay":
		validatePurchaseResponse, err := nk.PurchaseValidateGoogle(ctx, userId, iap.Payload, true)
		if err != nil {
			logger.Error("error validated purchases with Google")
			return "", runtime.NewError("error validated purchases with Google", 13)
		}
		rewardPurchases(ctx, userId, logger, nk, validatePurchaseResponse)
		break
	case "AppleAppStore":
		validatePurchaseResponse, err := nk.PurchaseValidateApple(ctx, userId, iap.Payload, true)
		if err != nil {
			logger.Error("error validated purchases with Apple")
			return "", runtime.NewError("error validated purchases with Apple", 13)
		}
		rewardPurchases(ctx, userId, logger, nk, validatePurchaseResponse)
		break
	default:
		logger.Warn("unrecognised app store in payload")
		return "", runtime.NewError("unrecognised app store", 13)
	}

	return "{}", nil
}

func rewardPurchases(ctx context.Context, userId string, logger runtime.Logger, nk runtime.NakamaModule, validatedPurchaseResponse *api.ValidatePurchaseResponse) {
	for _, p := range validatedPurchaseResponse.ValidatedPurchases {
		// Here we are just dealing with consumable IAPs
		switch p.ProductId {
		case "gems_100":
			walletUpdate := []*runtime.WalletUpdate{
				{
					UserID: userId,
					Changeset: map[string]int64{
						"gems": 100,
					},
				},
			}
			nk.WalletsUpdate(ctx, walletUpdate, true)
			break
		case "gems_1000":
			walletUpdate := []*runtime.WalletUpdate{
				{
					UserID: userId,
					Changeset: map[string]int64{
						"gems": 1000,
					},
				},
			}
			nk.WalletsUpdate(ctx, walletUpdate, true)
			break
		}
	}
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

프리미엄 화폐로 게임 화폐 구매하기 #

플레이어가 구매한 프리미엄 화폐로 게임 화폐인 코인을 구매할 수 있습니다. 다음의 RPC에서 사용자는 코인에 사용할 보석의 양을 지정할 수 있습니다. 여기서 환율(1 보석 = 1000 코인)은 변경할 수 없습니다.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
let RpcPurchaseCoins: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
  const request = JSON.parse(payload);

  if (!request.gemsToSpend) {
    logger.warn('No gemsToSpend specified in RpcPurchaseCoins payload.');
    return JSON.stringify({ success: false, error: 'Failed to provide gems to spend amount.' });
  }

  // Check that the user has enough gems to spend
  const account = nk.accountGetId(ctx.userId);
  if (account.wallet['gems'] < request.gemsToSpend) {
    logger.warn('User does not have enough gems.');
    return JSON.stringify({ success: false, error: 'Not enough gems.' });
  }

  // Spend
  const coinsPerGem = 1000;
  nk.walletUpdate(ctx.userId, { coins: coinsPerGem * request.gemsToSpend, gems: -request.gemsToSpend });

  return JSON.stringify({ success: true });
};
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
type PurchaseCoinsPayload struct {
	GemsToSpend int `json:"gemsToSpend"`
}

func RpcPurchaseCoins(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// Get the user ID
	userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok {
		logger.Error("no user id found")
		return "", runtime.NewError("no user id found", 16)
	}

	// Unmarshal the payload
	request := &PurchaseCoinsPayload{}
	if err := json.Unmarshal([]byte(payload), request); err != nil {
		logger.Error("error unmarshaling payload")
		return "", runtime.NewError("purchase coins payload invalid", 3)
	}

	if request.GemsToSpend <= 0 {
		logger.Warn("no gemsToSpend specified in payload")
		return "", runtime.NewError("no gemsToSpend specified in payload", 3)
	}

	// Check that the user has enough gems to spend
	account, err := nk.AccountGetId(ctx, userId)
	if err != nil {
		logger.Error("error getting account data")
		return "", runtime.NewError("error getting account data", 13)
	}

	var wallet map[string]int
	if err := json.Unmarshal([]byte(account.Wallet), &wallet); err != nil {
		logger.Error("error unmarshaling wallet")
		return "", runtime.NewError("error unmarshaling wallet", 13)
	}

	if wallet["gems"] < request.GemsToSpend {
		logger.Warn("user does not have enough gems")
		return "", runtime.NewError("you do not have enough gems", 9)
	}

	// Spend
	coinsPerGem := 1000
	coins := coinsPerGem *  request.GemsToSpend
	_, _, err = nk.WalletUpdate(ctx, userId, map[string]int64{"coins": int64(coins), "gems": int64(-request.GemsToSpend)}, nil, true)
	if err != nil {
		return "", runtime.NewError("unable to update wallet", 13)
	}

	return "{}", nil
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

게임 화폐로 아이템 구매하기 #

이번 예시에서는 Nakama 저장소 엔진 내에 구성 개체를 저장하여 게임에서 각 아이템을 코인의 가격으로 매핑합니다. 서버의 InitModule 기능 내에서 구성합니다.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const itemPrices = {
    'iron-sword': 100,
    'iron-shield': 150,
    'steel-sword': 500
  };

  const writeRequest: nkruntime.StorageWriteRequest = {
    collection: 'configuration',
    key: 'prices',
    userId: '00000000-0000-0000-0000-000000000000', // Owned by the system user
    permissionRead: 2, // Public read
    permissionWrite: 0, // No write
    value: itemPrices
  };

  nk.storageWrite([ writeRequest ]);
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
itemPrices := map[string]int64 {
  "iron-sword": 100,
  "iron-shield": 150,
  "steel-sword": 500,
}

itemJson, err := json.Marshal(itemPrices)
if err != nil {
  logger.Error("error marshaling item prices")
  return runtime.NewError("error marshaling item prices", 13)
}

write := &runtime.StorageWrite{
  Collection: "configuration",
  Key: "prices",
  UserID: "00000000-0000-0000-0000-000000000000",
  PermissionRead: 2, // Public read
  PermissionWrite: 0, // No write
  Value: string(itemJson),
}

if _, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{write}); err != nil {
  logger.Error("error writing storage objects")
  return runtime.NewError("error writing storage objects", 13)
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

가격이 저장소 엔진에 저장되어 있는 경우, 사용자는 RPC를 작성하여 아이템을 구매할 수 있습니다(코인이 충분한 경우).

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
let RpcPurchaseItem: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
  const request = JSON.parse(payload);

  // Make sure the user specified an item to buy
  if (!request.itemName) {
    logger.warn('No item name specified.');
    return JSON.stringify({ success: false, error: 'No item name specified.'});
  }

  // Lookup the item prices
  const readRequest: nkruntime.StorageReadRequest = {
    collection: 'configuration',
    key: 'prices',
    userId: '00000000-0000-0000-0000-000000000000'
  };

  const readResult = nk.storageRead([readRequest]);
  if (readResult.length == 0)
  {
    logger.warn('No item prices in storage.');
    return JSON.stringify({ success: false, error: 'No item prices available.' });
  }
  
  const prices = readResult[0].value;

  // Check if there is a price for the requested item
  if (!prices[request.itemName]) {
    logger.warn(`No price available for ${request.itemName}`);
    return JSON.stringify({ success: false, error: `No price available for ${request.itemName}` });
  }

  // Check that the player has enough coins
  const account = nk.accountGetId(ctx.userId);

  if (account.wallet['coins'] < prices[request.itemName]) {
    logger.warn('Not enough coins to purchase item.');
    return JSON.stringify({ success: false, error: 'Not enough coins to purchase item.' });
  }

  // Decrease the player's coins
  nk.walletUpdate(ctx.userId, { coins: -prices[request.itemName] });

  // Get the player's current inventory.
  let inventory = {};

  const inventoryReadRequest: nkruntime.StorageReadRequest = {
    collection: 'economy',
    key: 'inventory',
    userId: ctx.userId
  };

  const result = nk.storageRead([inventoryReadRequest]);
  if (result.length > 0) {
    inventory = result[0].value;
  }

  // Give the player the item (either increase quantity if they already possessed it or add one)
  if (inventory[request.itemName]) {
    inventory[request.itemName] += 1;
  } else {
    inventory[request.itemName] = 1;
  }

  // Define the storage write request to update the player's inventory.
  const writeRequest: nkruntime.StorageWriteRequest = {
    collection: 'economy',
    key: 'inventory',
    userId: ctx.userId,
    permissionWrite: 1,
    permissionRead: 1,
    value: inventory
  };

  // Write the updated inventory to storage.
  const storageWriteAck = nk.storageWrite([writeRequest]);

  // Return an error if the write does not succeed.
  if (!storageWriteAck || storageWriteAck.length == 0) {
    return JSON.stringify({ success: false, error: 'Error saving inventory.' });
  }

  return JSON.stringify({ success: true });
};
Server
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
type PurchaseItemPayload struct {
	ItemName string `json:"itemName"`
}

func RpcPurchaseItem(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// Get the user ID
	userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok {
		logger.Error("no user id found")
		return "", runtime.NewError("no user id found", 16)
	}

	// Unmarshal the payload
	request := &PurchaseItemPayload{}
	if err := json.Unmarshal([]byte(payload), request); err != nil {
		logger.Error("error unmarshaling payload", err)
		return "", runtime.NewError("purchase item payload invalid", 3)
	}

	// Make sure the user specified an item to buy
	if request.ItemName == "" {
		logger.Warn("no item name specified")
		return "", runtime.NewError("no item name specified", 3)
	}

	// Lookup the item prices
	readRequest := &runtime.StorageRead{
		Collection: "configuration",
		Key:        "prices",
		UserID:     "00000000-0000-0000-0000-000000000000",
	}

	readResult, err := nk.StorageRead(ctx, []*runtime.StorageRead{readRequest})
	if err != nil {
		logger.Error("error reading item prices from storage", err)
		return "", runtime.NewError("error reading item prices from storage", 13)
	}

	if len(readResult) == 0 {
		logger.Warn("no item prices in storage")
		return "", runtime.NewError("no item prices in storage", 13)
	}

	// Check if there is a price for the requested item
	var prices map[string]int
	if err := json.Unmarshal([]byte(readResult[0].Value), &prices); err != nil {
		logger.Error("error unmarshaling prices", err)
		return "", runtime.NewError("error unmarshaling prices", 13)
	}

	if _, ok := prices[request.ItemName]; !ok {
		logger.Warn("no price available for %s", request.ItemName)
		return "", runtime.NewError(fmt.Sprintf("no price available for %s", request.ItemName), 5)
	}

	// Check that the player has enough coins to spend
	account, err := nk.AccountGetId(ctx, userId)
	if err != nil {
		logger.Error("error getting account data", err)
		return "", runtime.NewError("error getting account data", 13)
	}

	var wallet map[string]int
	if err := json.Unmarshal([]byte(account.Wallet), &wallet); err != nil {
		logger.Error("error unmarshaling wallet", err)
		return "", runtime.NewError("error unmarshaling wallet", 13)
	}

	if wallet["coins"] < prices[request.ItemName] {
		logger.Warn("not enough coins to purchase item")
		return "", runtime.NewError("not enough coins to purchase item", 9)
	}

	// Decrease the player's coins
	_, _, err = nk.WalletUpdate(ctx, userId, map[string]int64{"coins": int64(-prices[request.ItemName])}, nil, true)
	if err != nil {
		logger.Error("unable to update wallet", err)
		return "", runtime.NewError("unable to update wallet", 13)
	}

	// Get the player's current inventory
	var inventory map[string]int

	readRequest = &runtime.StorageRead{
		Collection: "economy",
		Key:        "inventory",
		UserID:     userId,
	}

	readResult, err = nk.StorageRead(ctx, []*runtime.StorageRead{readRequest})
	if err != nil {
		logger.Error("error reading inventory from storage", err)
		return "", runtime.NewError("error reading inventory from storage", 13)
	}

	if len(readResult) > 0 {
		if err := json.Unmarshal([]byte(readResult[0].Value), &inventory); err != nil {
			logger.Error("error unmarshaling inventory", err)
			return "", runtime.NewError("error unmarshaling inventory", 13)
		}
	} else {
		inventory = make(map[string]int)
	}

	// Give the player the item (either increase quantity if they already possessed it or add one)
	if _, ok := inventory[request.ItemName]; ok {
		inventory[request.ItemName] += 1
	} else {
		inventory[request.ItemName] = 1
	}

	// Write the updated inventory to storage
	inventoryJson, err := json.Marshal(inventory)
	if err != nil {
		logger.Error("error marshaling inventory")
		return "", runtime.NewError("error marshaling inventory", 13)
	}

	writeRequest := &runtime.StorageWrite{
		Collection:      "economy",
		Key:             "inventory",
		UserID:          userId,
		PermissionRead:  1,
		PermissionWrite: 1,
		Value:           string(inventoryJson),
	}

	// Return an error if the write does not succeed
	storageWriteAck, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{writeRequest})
	if err != nil || len(storageWriteAck) == 0 {
		logger.Error("error saving inventory")
		return "", runtime.NewError("error saving inventory", 13)
	}

	return "{}", nil
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

IAP로 비소모품 구매하기 #

인앱 구매를 통해 가상 화폐를 구매하는 것 외에 플레이어가 비소모품을 직접 구매하도록 하는 기능을 제공할 수도 있습니다.

이번 예시에서는 플레이어가 보석을 구매할 수 있는 서버 런타임 RPC에 대해서 살펴보겠습니다. 향후에 다른 장치에서 복구할 수 있는 비소모품을 구매할 수 있는 기능을 추가하겠습니다. 플레이어가 이 인스턴스에서 비소모품을 구매하는 것은 프리미엄 상태에서 가능합니다.

구매에 대한 유효성 검사를 진행한 후, 사용자의 메타데이터에 플래그를 설정하여 프리미엄 플레이어를 표시합니다. 게임에서 다양한 혜택/보상을 제공하기 위해서 사용할 수 있습니다.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let rewardPurchase = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, validatedPurchase: nkruntime.ValidatedPurchase): void {
  switch (validatedPurchase.productId) {
    // ...existing cases omitted
    case 'premium_status':
      const account = nk.accountGetId(ctx.userId);
      const metadata = account.user.metadata;
      metadata['premium'] = true;
      nk.accountUpdateId(ctx.userId, null, null, null, null, null, null, metadata);
      break;
  }
};
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func rewardPurchases(ctx context.Context, userId string, logger runtime.Logger, nk runtime.NakamaModule, validatedPurchaseResponse *api.ValidatePurchaseResponse) {
	for _, p := range validatedPurchaseResponse.ValidatedPurchases {
		switch p.ProductId {
		// ...existing cases omitted
		case "premium_status":
			account, err := nk.AccountGetId(ctx, userId)
			if err != nil {
				logger.Error("unable to get account", err)
				return
			}

			var metadata map[string]interface{}
			if err := json.Unmarshal([]byte(account.User.Metadata), &metadata); err != nil {
				logger.Error("error unmarshaling account metadata", err)
				return
			}

			metadata["premium"] = true
			nk.AccountUpdateId(ctx, userId, "", metadata, "", "", "", "", "")
			break
		}
	}
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

IAP 구매 복구하기 #

사용자가 장치를 변경한 경우, 이전에 구매한 내역을 복구할 수 있어야 합니다. 이것은 구매한 시점에 이미 “소비"한 소모품(예: 가상 화폐)에는 적용되지 않지만, 전체 게임 잠금 해제, 광고 제거, 프리미엄 멤버 자격과 같이 어떤 장치에 게임을 설치하더라도 사용자에게 똑같은 혜택이 제공되어야 합니다.

이를 위해서 제공되는 RPC에서 게임 클라이언트는 IAP 제품 ID 목록을 받고 Nakama에서 구매가 검증된 타임스탬프를 구매할 수 있습니다. 클라이언트는 이 정보를 사용하여 새로운 장치에 적절한 기능을 복구할 수 있습니다.

Server
1
2
3
4
5
6
7
8
9
let RpcRestorePurchases: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
  const purchases = nk.purchasesList(ctx.userId);

  const response = {
    purchases = purchases.validatedPurchases.map(v => { v.productId, v.purchaseTime })
  };

  return JSON.stringify(response);
};
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
type RestorePurchasesResponse struct {
	Purchases []*ValidatedPurchaseItem `json:"purchases"`
}

type ValidatedPurchaseItem struct {
	ProductId    string `json:"productId"`
	PurchaseTime int64  `json:"purchaseTime"`
}

func RpcRestorePurchases(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// Get the user ID
	userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok {
		logger.Error("no user id found")
		return "", runtime.NewError("no user id found", 16)
	}

	// Get the list of validated purchases
	purchases, err := nk.PurchasesList(ctx, userId, 100, "")
	if err != nil {
		logger.Error("error retrieving purchases", err)
		return "", runtime.NewError("error retrieving purchases", 13)
	}

	// Construct the response
	purchasesResponse := &RestorePurchasesResponse{
		Purchases: []*ValidatedPurchaseItem{},
	}

	for _, purchase := range purchases.ValidatedPurchases {
		purchasesResponse.Purchases = append(purchasesResponse.Purchases, &ValidatedPurchaseItem{ProductId: purchase.ProductId, PurchaseTime: purchase.PurchaseTime.Seconds})
	}

	// Marshal the response
	jsonResponse, err := json.Marshal(purchasesResponse)
	if err != nil {
		logger.Error("error marshaling response", err)
		return "", runtime.NewError("error marshaling response", 13)
	}

	return string(jsonResponse), nil
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.